action slot

This commit is contained in:
John Lancaster
2026-09-01 23:32:14 -05:00
parent bf11b7865d
commit b87b1df642
3 changed files with 261 additions and 38 deletions
@@ -47,7 +47,7 @@ Load an example only when its exact mechanic matches the task:
- [binding transforms](./examples/data_binding.py): `bindable_dataclass`, `ui.date`, and typed `forward`/`backward` conversion. - [binding transforms](./examples/data_binding.py): `bindable_dataclass`, `ui.date`, and typed `forward`/`backward` conversion.
- [select events](./examples/select_events.py): `on_change`, generic Quasar events, `update:model-value`, browser-to-Python payload forwarding, and programmatic value changes. - [select events](./examples/select_events.py): `on_change`, generic Quasar events, `update:model-value`, browser-to-Python payload forwarding, and programmatic value changes.
- [table customization](./examples/table_customization.py): column defaults, dynamic formatting and classes, QTable props, responsive density, toolbar and cell slots, filtering, visible columns, and empty states. - [table customization](./examples/table_customization.py): raw-value sorting with cosmetic prefix, suffix, and datetime formatting; dynamic classes; QTable props; toolbar and cell slots; filtering; visible columns; and empty states.
- [editable table](./examples/editable_table.py): dataframe-to-row state, named QTable cell slots, controlled editors, dialog-based whole-row save/cancel edits, Python validation, touched rows, and canonical row refresh. - [editable table](./examples/editable_table.py): dataframe-to-row state, named QTable cell slots, controlled editors, dialog-based whole-row save/cancel edits, Python validation, touched rows, and canonical row refresh.
## Defaults That Span References ## Defaults That Span References
@@ -59,6 +59,7 @@ Load an example only when its exact mechanic matches the task:
- Prefer event-driven updates and explicit refreshes to unrelated polling. - Prefer event-driven updates and explicit refreshes to unrelated polling.
- Keep browser-originated values as proposals; validate and normalize them in Python before mutating authoritative state. - Keep browser-originated values as proposals; validate and normalize them in Python before mutating authoritative state.
- Prefer NiceGUI context managers and `ui.*` elements over raw Vue templates. Keep application logic and authoritative state in Python; use minimal browser expressions only for scoped-slot values or client-only behavior, following [Python-owned slot composition](./references/component-mechanics.md#prefer-python-owned-composition). - Prefer NiceGUI context managers and `ui.*` elements over raw Vue templates. Keep application logic and authoritative state in Python; use minimal browser expressions only for scoped-slot values or client-only behavior, following [Python-owned slot composition](./references/component-mechanics.md#prefer-python-owned-composition).
- For each presentation requirement, check the component's typed Python arguments and helpers before using `.props(...)` or `.classes(...)`. Create an application class and add CSS only when no Python API, documented component prop or slot, or existing utility class can express the requirement.
- Use NiceGUI context managers for element structure and Tailwind for generic layout, spacing, responsive behavior, and typography. Keep Quasar classes for semantic palette roles or component-specific geometry, and use Quasar props for component behavior and density; see [combining Tailwind with Quasar utilities](./references/styling-and-customization.md#combine-tailwind-with-quasar-utilities-deliberately). - Use NiceGUI context managers for element structure and Tailwind for generic layout, spacing, responsive behavior, and typography. Keep Quasar classes for semantic palette roles or component-specific geometry, and use Quasar props for component behavior and density; see [combining Tailwind with Quasar utilities](./references/styling-and-customization.md#combine-tailwind-with-quasar-utilities-deliberately).
- Provide loading, success, and failure states for user-triggered work. - Provide loading, success, and failure states for user-triggered work.
- Verify component-specific behavior against the installed NiceGUI version and its bundled Quasar version. - Verify component-specific behavior against the installed NiceGUI version and its bundled Quasar version.
@@ -5,6 +5,7 @@
# ] # ]
# /// # ///
from nicegui import events
from nicegui import ui from nicegui import ui
type TableValue = str | int | float type TableValue = str | int | float
@@ -17,6 +18,12 @@ STATUS_COLORS = {
} }
COLUMNS = [ COLUMNS = [
{
"name": "actions",
"label": "Actions",
"required": True,
"align": "center",
},
{ {
"name": "name", "name": "name",
"label": "Product", "label": "Product",
@@ -47,6 +54,7 @@ COLUMNS = [
"headerStyle": "width: 7rem", "headerStyle": "width: 7rem",
"style": "width: 7rem", "style": "width: 7rem",
":classes": "row => row.stock < 10 ? 'text-negative font-bold' : ''", ":classes": "row => row.stock < 10 ? 'text-negative font-bold' : ''",
":format": "value => value == null ? '' : `${value} units`",
}, },
{ {
"name": "price", "name": "price",
@@ -56,7 +64,7 @@ COLUMNS = [
"align": "right", "align": "right",
"headerStyle": "width: 8rem", "headerStyle": "width: 8rem",
"style": "width: 8rem", "style": "width: 8rem",
":format": "value => `$${value.toFixed(2)}`", ":format": "value => value == null ? '' : `$${value.toFixed(2)}`",
}, },
{ {
"name": "status", "name": "status",
@@ -68,15 +76,85 @@ COLUMNS = [
"style": "width: 8rem", "style": "width: 8rem",
"colorByValue": STATUS_COLORS, "colorByValue": STATUS_COLORS,
}, },
{
"name": "updated_at",
"label": "Updated",
"field": "updated_at",
"sortable": True,
"align": "left",
"headerStyle": "width: 13rem",
"style": "width: 13rem",
":sort": "(left, right) => Date.parse(left) - Date.parse(right)",
":format": """(() => {
const formatter = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC',
});
return value => {
if (!value) return '';
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 'Invalid date' : formatter.format(timestamp);
};
})()""",
},
] ]
ROWS: list[TableRow] = [ ROWS: list[TableRow] = [
{"id": 101, "name": "Desk lamp", "category": "Lighting", "stock": 7, "price": 42.5, "status": "Low"}, {
{"id": 102, "name": "Task chair", "category": "Seating", "stock": 18, "price": 289.0, "status": "Ready"}, "id": 101,
{"id": 103, "name": "Monitor arm", "category": "Hardware", "stock": 0, "price": 119.95, "status": "Backorder"}, "name": "Desk lamp",
{"id": 104, "name": "Cable tray", "category": "Hardware", "stock": 34, "price": 31.25, "status": "Ready"}, "category": "Lighting",
{"id": 105, "name": "Side table", "category": "Furniture", "stock": 9, "price": 164.5, "status": "Low"}, "stock": 7,
{"id": 106, "name": "Floor light", "category": "Lighting", "stock": 15, "price": 98.0, "status": "Ready"}, "price": 42.5,
"status": "Low",
"updated_at": "2026-08-31T16:20:00Z",
},
{
"id": 102,
"name": "Task chair",
"category": "Seating",
"stock": 18,
"price": 289.0,
"status": "Ready",
"updated_at": "2026-09-01T08:45:00Z",
},
{
"id": 103,
"name": "Monitor arm",
"category": "Hardware",
"stock": 0,
"price": 119.95,
"status": "Backorder",
"updated_at": "2026-08-29T11:05:00Z",
},
{
"id": 104,
"name": "Cable tray",
"category": "Hardware",
"stock": 34,
"price": 31.25,
"status": "Ready",
"updated_at": "2026-09-01T14:30:00Z",
},
{
"id": 105,
"name": "Side table",
"category": "Furniture",
"stock": 9,
"price": 164.5,
"status": "Low",
"updated_at": "2026-08-30T19:15:00Z",
},
{
"id": 106,
"name": "Floor light",
"category": "Lighting",
"stock": 15,
"price": 98.0,
"status": "Ready",
"updated_at": "2026-09-01T10:10:00Z",
},
] ]
@@ -112,10 +190,10 @@ def render_table() -> ui.table:
) )
) )
with table.add_slot("top-left"), ui.row().classes("items-center gap-2"): with table.add_slot("top-left"), ui.row(align_items="center").classes("gap-2"):
ui.icon("inventory_2", size="sm").classes("text-primary") ui.icon("inventory_2", size="sm", color="primary")
ui.label("Inventory").classes("text-xl font-medium") ui.label("Inventory").classes("text-xl font-medium")
ui.badge(str(len(ROWS)), color="grey-3").props("text-color=grey-9") ui.badge(str(len(ROWS)), color="grey-3", text_color="grey-9")
with table.add_slot("top-right"): with table.add_slot("top-right"):
ui.input(placeholder="Search inventory").props("dense outlined clearable debounce=250").bind_value_to( ui.input(placeholder="Search inventory").props("dense outlined clearable debounce=250").bind_value_to(
@@ -124,20 +202,36 @@ def render_table() -> ui.table:
) )
with table.add_slot("body-cell-status"), table.cell("status"): with table.add_slot("body-cell-status"), table.cell("status"):
ui.badge().props(':label="props.value" :color="props.col.colorByValue[props.value] ?? \'grey\'" outline') ui.badge(outline=True).props(':label="props.value" :color="props.col.colorByValue[props.value] ?? \'grey\'"')
with table.add_slot("no-data"), ui.row().classes("w-full items-center justify-center gap-2 p-6 text-grey-7"): def open_product(event: events.GenericEventArguments) -> None:
product = next((row for row in table.rows if row[table.row_key] == event.args), None)
if product is None:
ui.notify("Product no longer exists", type="negative")
return
ui.notify(f"Opening {product['name']}")
with (
table.add_slot("body-cell-actions"),
table.cell("actions"),
ui.button(icon="open_in_new")
.props("round flat size='sm'")
.on(
"click.stop",
handler=open_product,
js_handler="() => emit(props.key)",
),
):
ui.tooltip("Open product")
with table.add_slot("no-data"), ui.row(align_items="center").classes("w-full justify-center gap-2 p-6 text-grey-7"):
ui.icon("inventory_2", size="2em").props(":name=\"props.filter ? 'filter_alt_off' : 'inventory_2'\"") ui.icon("inventory_2", size="2em").props(":name=\"props.filter ? 'filter_alt_off' : 'inventory_2'\"")
ui.element("span").props(':textContent="props.message"') ui.element("span").props(':textContent="props.message"')
optional_columns = [column for column in table.columns if not column.get("required")] optional_columns = [column for column in table.columns if not column.get("required")]
def set_visible_columns(names: list[str]) -> None: def set_visible_columns(names: list[str]) -> None:
visible = set(names) table.props["visible-columns"] = names
for column in optional_columns:
hidden = column["name"] not in visible
column["classes"] = "hidden" if hidden else ""
column["headerClasses"] = "hidden" if hidden else "text-grey-8"
table.update() table.update()
ui.select( ui.select(
@@ -145,6 +239,7 @@ def render_table() -> ui.table:
value=[column["name"] for column in optional_columns], value=[column["name"] for column in optional_columns],
label="Visible columns", label="Visible columns",
multiple=True, multiple=True,
clearable=True,
on_change=lambda event: set_visible_columns(event.value), on_change=lambda event: set_visible_columns(event.value),
).props("outlined dense options-dense").classes("w-64") ).props("outlined dense options-dense").classes("w-64")
@@ -152,7 +247,7 @@ def render_table() -> ui.table:
if __name__ in {"__main__", "__mp_main__"}: if __name__ in {"__main__", "__mp_main__"}:
with ui.column().classes("w-full items-center gap-4 p-4"): with ui.column(align_items="center").classes("w-full gap-4 p-4"):
render_table() render_table()
ui.run(port=8888, reload=True) ui.run(port=8888, reload=True)
@@ -29,17 +29,16 @@ NiceGUI's tagged [table client wrapper](https://github.com/zauberzeug/nicegui/bl
## Columns Before Slots ## Columns Before Slots
A QTable column is both a data projection and a presentation contract. Keep its `name` unique because sorting and `body-cell-[name]` slot selection use it. Keep `field` separate when the displayed column name differs from the row key. A QTable column is both a data projection and a presentation contract. Keep its `name` unique because sorting and `body-cell-[name]` slot selection use it. `field` identifies or computes the raw cell value; it does not need to match the column name.
```python ```python
columns = [ columns = [
{ {
"name": "price", "name": "available",
"label": "Unit price", "label": "In stock",
"field": "price", "field": "stock",
"sortable": True, "sortable": True,
"align": "right", "align": "right",
":format": "value => `$${value.toFixed(2)}`",
}, },
] ]
@@ -53,6 +52,95 @@ table = ui.table(
Use plain keys such as `classes`, `style`, `headerClasses`, and `headerStyle` for static values. NiceGUI's client-side dynamic-property conversion recognizes colon-prefixed keys such as `:field`, `:format`, `:sort`, `:classes`, and `:style` as JavaScript expressions. Keep row data JSON-serializable; format display values in a column or slot rather than putting component objects in rows. Use plain keys such as `classes`, `style`, `headerClasses`, and `headerStyle` for static values. NiceGUI's client-side dynamic-property conversion recognizes colon-prefixed keys such as `:field`, `:format`, `:sort`, `:classes`, and `:style` as JavaScript expressions. Keep row data JSON-serializable; format display values in a column or slot rather than putting component objects in rows.
### Raw Values And Cosmetic Display Formatting
Use the column's `format(value, row)` function when only the displayed cell text should change. QTable's tagged [`getCellValue`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTable.js) resolves `field` first and then passes that raw value through `format`. The formatted result is used by the default cell renderer and exposed to `body-cell-*` slots as `props.value`.
Keep the layers distinct:
| Layer | Owns | Effect |
| --- | --- | --- |
| row value | canonical JSON-serializable data sent by Python | remains available as `props.row.<field>` |
| `field` | raw value extraction or derivation | supplies sorting and the input to `format` |
| `format` | cosmetic text projection | changes default rendering, `props.value`, and local default-filter matching |
| `body-cell-*` slot | component structure around the value | use for badges, icons, links, controls, or multiple elements |
Formatting does not mutate `table.rows` or the row object. QTable's tagged [sorting implementation](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-sort.js) compares raw `field` values, while its tagged [default filter](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-filter.js) searches the formatted values returned by `getCellValue`. This is usually desirable: numbers sort numerically while users can search what they see.
#### Prefix And Suffix Text
Use a null-safe `:format` expression for simple prefix or suffix text:
```python
columns = [
{
"name": "price",
"label": "Unit price",
"field": "price",
"sortable": True,
":format": "value => value == null ? '' : `$${value.toFixed(2)}`",
},
{
"name": "stock",
"label": "In stock",
"field": "stock",
"sortable": True,
":format": "value => value == null ? '' : `${value} units`",
},
]
```
The underlying values stay numeric, so sorting remains numeric. Check `value == null` rather than `if (!value)` when zero is valid; `0` must render as `$0.00` or `0 units`, not as an empty cell. Use a named cell slot instead when the prefix or suffix needs separate styling, an icon, a tooltip, or accessible text that differs from the visible text.
#### Datetime Text
Send datetimes as ISO 8601 strings with an explicit offset. Python's [`datetime.isoformat()`](https://docs.python.org/3/library/datetime.html#datetime.datetime.isoformat) includes an offset for aware values, and [`astimezone()`](https://docs.python.org/3/library/datetime.html#datetime.datetime.astimezone) preserves the represented instant while converting zones:
```python
from datetime import UTC, datetime
updated_at = datetime.now(UTC)
row = {"updated_at": updated_at.astimezone(UTC).isoformat()}
```
Avoid locale-formatted strings and naive datetime strings as transport values. JavaScript guarantees support for its standard ISO date-time format, but [date-time strings without an offset](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse) are interpreted in the browser's local timezone when they contain both a date and time. An explicit `Z` or `+00:00` identifies the instant unambiguously.
For repeated cells, construct one [`Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat) and return a formatter closure. NiceGUI evaluates the colon-prefixed expression into the column function, so the formatter is reused instead of performing locale-data lookup for every cell:
```python
columns = [
{
"name": "updated_at",
"label": "Updated",
"field": "updated_at",
"sortable": True,
":sort": "(left, right) => Date.parse(left) - Date.parse(right)",
":format": """(() => {
const formatter = new Intl.DateTimeFormat('en-US', {
dateStyle: 'medium',
timeStyle: 'short',
timeZone: 'UTC',
});
return value => {
if (!value) return '';
const timestamp = Date.parse(value);
return Number.isNaN(timestamp) ? 'Invalid date' : formatter.format(timestamp);
};
})()""",
},
]
```
Choose locale and timezone deliberately:
- Use a fixed locale such as `en-US` when the product requires one stable language convention; use `undefined` to follow the browser's locale.
- Set `timeZone` to `UTC` or an IANA zone such as `America/New_York` for deterministic product behavior; omit it only when each viewer should see browser-local time.
- Keep invalid-value handling explicit. `Date.parse()` returns `NaN` for an invalid value, while passing an invalid date directly to the formatter can throw.
Sorting still receives the raw ISO value from `field`; it never compares the formatted label. The explicit `:sort` function above converts those raw strings to epoch milliseconds, so values with different offsets are ordered by instant while cells keep their localized display text. Validate datetime strings before sending them because an invalid value makes `Date.parse()` return `NaN`. For a simpler data contract, send epoch milliseconds as the field value and format that number directly.
Lexicographic sorting without a custom `sort` function is chronological only when every ISO string uses the same fixed-width representation and offset, as in normalized UTC values. Local filtering is intentionally different: it matches the formatted text, so searches follow the chosen locale and timezone rather than the raw ISO string.
### Cell Classes And Styles ### Cell Classes And Styles
Quasar's tagged [`table-column-selection.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-column-selection.js), [`QTh.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTh.js), and [`QTd.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTd.js) establish the exact targets: Quasar's tagged [`table-column-selection.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/table-column-selection.js), [`QTh.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTh.js), and [`QTd.js`](https://github.com/quasarframework/quasar/blob/quasar-v2.18.5/ui/src/components/table/QTd.js) establish the exact targets:
@@ -98,34 +186,31 @@ Use the following mechanisms in order:
| Keep utility or action cells compact | Use `auto-width` on `table.header(...)` and `table.cell(...)` in slots, or Quasar's `q-table--col-auto-width` class on both header and body; it sets `width: 1px` and content supplies the floor | | Keep utility or action cells compact | Use `auto-width` on `table.header(...)` and `table.cell(...)` in slots, or Quasar's `q-table--col-auto-width` class on both header and body; it sets `width: 1px` and content supplies the floor |
| Allow readable narrow layouts | Enable `wrap-cells`, set a practical `min-width` for key columns, and let QTable's middle container scroll horizontally when the total minimum exceeds the viewport | | Allow readable narrow layouts | Enable `wrap-cells`, set a practical `min-width` for key columns, and let QTable's middle container scroll horizontally when the total minimum exceeds the viewport |
| Guarantee a column allocation | Use fixed table layout, an explicit table width, matching first-row/header widths, and an explicit overflow policy | | Guarantee a column allocation | Use fixed table layout, an explicit table width, matching first-row/header widths, and an explicit overflow policy |
| Truncate text | Use a fixed/constrained track plus a block wrapper with `overflow: hidden; white-space: nowrap; text-overflow: ellipsis`; [`text-overflow`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow) does not create overflow by itself | | Truncate text | Use a fixed/constrained track plus a block wrapper with Tailwind's `truncate` utility; [`text-overflow`](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/text-overflow) does not create overflow by itself |
For ordinary data tables, automatic layout plus a few minimum or target widths is the idiomatic default. Apply matching values to `headerStyle` and `style`: one declaration is often enough to influence the shared track, but matching declarations make intent explicit, survive empty datasets, and keep custom header/body renderers consistent. For ordinary data tables, automatic layout plus a few minimum or target widths is the idiomatic default. Apply matching values to `headerStyle` and `style`: one declaration is often enough to influence the shared track, but matching declarations make intent explicit, survive empty datasets, and keep custom header/body renderers consistent.
Use fixed layout only when predictable allocation matters more than content-driven sizing. QTable's `table-style` prop styles the scrolling wrapper `<div>`, not the nested native `<table>`, so it cannot set `table-layout`. Add a class to the `ui.table` element and target the nested `.q-table` with scoped CSS: Use fixed layout only when predictable allocation matters more than content-driven sizing. QTable's `table-style` prop styles the scrolling wrapper `<div>`, not the nested native `<table>`, so it cannot set `table-layout`. This nested element cannot receive a utility class through NiceGUI or QTable's public API, making one application class and one scoped CSS rule necessary. Keep truncation on a Python-created wrapper with Tailwind's `truncate` utility:
```python ```python
ui.add_css(""" ui.add_css("""
.inventory-table .q-table { .inventory-table .q-table {
table-layout: fixed; table-layout: fixed;
width: 100%;
}
.inventory-table .truncate-cell > * {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
""") """)
table = ui.table(rows=rows, columns=columns).classes("inventory-table w-full") table = ui.table(rows=rows, columns=columns).classes("inventory-table w-full")
with table.add_slot("body-cell-name"), table.cell("name"):
ui.element("span").props(':textContent="props.value"').classes("block truncate")
``` ```
With fixed layout, the table must have a non-automatic width. The first row's explicit widths determine tracks without a `<colgroup>`; later body content does not resize them and therefore needs wrapping, clipping, or scrolling. A full custom header changes that first-row contract, so retest every column after introducing one. With fixed layout, the table must have a non-automatic width; QTable already gives its native table `width: 100%`, while `w-full` constrains the NiceGUI element. The first row's explicit widths determine tracks without a `<colgroup>`; later body content does not resize them and therefore needs wrapping, clipping, or scrolling. A full custom header changes that first-row contract, so retest every column after introducing one.
Column visibility has two useful patterns: Column visibility has two useful patterns:
- Mark identity or action columns `required` when they must remain visible. - Mark identity or action columns `required` when they must remain visible.
- For a Python-owned column picker, update each optional column in `table.columns`, then call `table.update()`. Use the table-owned dictionaries rather than the input list because `column_defaults` normalizes columns into new dictionaries. This pattern also avoids binding a Python list directly into a JavaScript expression. - For a Python-owned column picker, assign the selected column names to `table.props["visible-columns"]`, then call `table.update()`. QTable automatically includes columns marked `required`. Assign the Python list directly to the prop rather than interpolating it into a JavaScript expression.
## QTable Props ## QTable Props
@@ -196,6 +281,45 @@ Inside table slots, `props.value` is the parsed and formatted cell value, `props
A slot template is reused for every matching row, so `props.value`, `props.row`, and `props.col` are JavaScript expressions, not Python variables. Keep authorization and business-state decisions out of this mapping; it is client-visible presentation metadata. A slot template is reused for every matching row, so `props.value`, `props.row`, and `props.col` are JavaScript expressions, not Python variables. Keep authorization and business-state decisions out of this mapping; it is client-visible presentation metadata.
### Add A Button To A Cell
Add an action column to the table's column definitions, then target it with the corresponding `body-cell-[name]` slot. Use a NiceGUI `ui.button` rather than writing a raw QBtn template, and wrap it in `table.cell(column_name)` so QTable preserves the cell's alignment, classes, styles, and table semantics.
The slot's `props.key` is the primitive identity derived from the table's `row_key`. Forward that key to Python instead of sending the full browser-side row object. Resolve the current authoritative row again in the handler because the record may have changed or disappeared since the table was rendered:
```python
from nicegui import events, ui
columns = [
{"name": "name", "label": "Product", "field": "name", "align": "left"},
{"name": "actions", "label": "Actions", "required": True, "align": "center"},
]
rows = [
{"id": 101, "name": "Desk lamp"},
{"id": 102, "name": "Task chair"},
]
table = ui.table(rows=rows, columns=columns, row_key="id")
def open_product(event: events.GenericEventArguments) -> None:
product = next((row for row in table.rows if row[table.row_key] == event.args), None)
if product is None:
ui.notify("Product no longer exists", type="negative")
return
ui.notify(f"Opening {product['name']}")
with table.add_slot("body-cell-actions"), table.cell("actions"):
action_button = ui.button(icon="open_in_new").props("round flat size='sm'").on(
"click.stop",
handler=open_product,
js_handler="() => emit(props.key)",
)
with action_button:
ui.tooltip("Open product")
```
The compact icon-only button uses QBtn's `round`, `flat`, and `size` props to avoid a visually heavy filled action in every row. Nest `ui.tooltip` inside the button so each row's cloned QTooltip uses its own parent as the target. Do not call `action_button.tooltip(...)` in a reused scoped slot: NiceGUI implements that convenience method with an element-ID target, and every clone would resolve to the first button. The `.on(...)` bridge is necessary here because `props.key` exists only in the reused browser-side slot scope; a normal Python `on_click` callback cannot capture a different row for each rendered instance. The `click.stop` modifier prevents the button click from also reaching a row-click handler. Treat the key as untrusted input and repeat authorization, existence, and state checks before performing the real action.
Replacing the full `body` or `header` slot also replaces behavior QTable would otherwise generate. Render `QTr` plus `QTd` or `QTh`, pass the scoped props through, preserve unique row keys, and retest sorting, selection, focus, and responsive behavior. In particular, QTable row-click events are not emitted when a full `body`, `row`, or `item` slot owns the structure. Replacing the full `body` or `header` slot also replaces behavior QTable would otherwise generate. Render `QTr` plus `QTd` or `QTh`, pass the scoped props through, preserve unique row keys, and retest sorting, selection, focus, and responsive behavior. In particular, QTable row-click events are not emitted when a full `body`, `row`, or `item` slot owns the structure.
## Toolbar Search ## Toolbar Search
@@ -217,7 +341,7 @@ The path from keystroke to displayed rows is:
4. QTable filters first, sorts the matching rows, resets pagination to page 1 when the filter changes, and then slices the current page. 4. QTable filters first, sorts the matching rows, resets pagination to page 1 when the filter changes, and then slices the current page.
5. When no row matches, the `no-data` slot receives the configured `no-results-label` as `message` and a truthy `filter`; when the underlying row list is empty without a filter, it receives `no-data-label` and a falsy `filter`. 5. When no row matches, the `no-data` slot receives the configured `no-results-label` as `message` and a truthy `filter`; when the underlying row list is empty without a filter, it receives `no-data-label` and a falsy `filter`.
The default matcher uses each column's resolved `field` and then its `format` function. In this example, searches can therefore match displayed values such as `Hardware`, `Backorder`, or `$31.25`; the row ID is not searchable because it has no column. QTable passes its computed columns to the matcher. A column excluded with QTable's `visible-columns` prop is not searched, while a column hidden only with CSS classes, as in this example's Python-owned picker, remains part of the search. The default matcher uses each column's resolved `field` and then its `format` function. In this example, searches can therefore match displayed values such as `Hardware`, `Backorder`, or `$31.25`; the row ID is not searchable because it has no column. QTable passes its computed columns to the matcher, so a column excluded with the `visible-columns` prop is not searched. Hiding a column with CSS classes would leave it in the search set and should not be used as a substitute for the component prop.
This is client-side filtering over rows already sent to the browser. Do not load a large dataset merely to search it locally. When pagination contains `rowsNumber`, QTable switches to server-side mode, stops applying its local matcher, and emits a `request` carrying the filter and pagination state; validate the term and query the authoritative data source in that handler. Add explicit backend search semantics for field scope, tokenization, locale, and ranking instead of assuming they match QTable's substring behavior. This is client-side filtering over rows already sent to the browser. Do not load a large dataset merely to search it locally. When pagination contains `rowsNumber`, QTable switches to server-side mode, stops applying its local matcher, and emits a `request` carrying the filter and pagination state; validate the term and query the authoritative data source in that handler. Add explicit backend search semantics for field scope, tokenization, locale, and ranking instead of assuming they match QTable's substring behavior.
@@ -227,7 +351,7 @@ Prefer typed table helpers over raw frontend calls. Use `table.run_method(...)`
## Runnable Example ## Runnable Example
The complete example combines column defaults, static and dynamic column attributes, responsive QTable props, filtering, column visibility, a toolbar, a custom status cell, and a filtered-empty state. It is available as [`table_customization.py`](../examples/table_customization.py) and as `skill://nicegui/examples/table_customization.py`. The complete example combines raw-value sorting with cosmetic prefix, suffix, and datetime formatting; column defaults; responsive QTable props; filtering; column visibility; a toolbar; custom status and action cells; and a filtered-empty state. It is available as [`table_customization.py`](../examples/table_customization.py) and as `skill://nicegui/examples/table_customization.py`.
```python title="table_customization.py" ```python title="table_customization.py"
--8<-- "docs/skills/nicegui/examples/table_customization.py" --8<-- "docs/skills/nicegui/examples/table_customization.py"
@@ -239,7 +363,7 @@ Run it from the repository root:
uv run src/personal_mcp/docs/skills/nicegui/examples/table_customization.py uv run src/personal_mcp/docs/skills/nicegui/examples/table_customization.py
``` ```
Verify that searches are case-insensitive, match formatted values across columns, reset to the first page, and change the empty-state message when no rows match. Confirm that optional columns can be hidden and restored and that the status cell retains its alignment and badge styling. Resize the browser across mobile, landscape desktop, and portrait desktop widths; the toolbar must remain usable and the table must scroll without overlapping controls. Verify that stock and price sort by their numeric row values while displaying suffix and prefix text. Verify that updated timestamps sort chronologically by their raw ISO values while displaying localized UTC text. Confirm that searches are case-insensitive, match formatted values across columns, reset to the first page, and change the empty-state message when no rows match. Confirm that optional columns can be hidden and restored, the status cell retains its alignment and badge styling, and each action button reports the product from its own row. Resize the browser across mobile, landscape desktop, and portrait desktop widths; the toolbar must remain usable and the table must scroll without overlapping controls.
## Escalation Boundaries ## Escalation Boundaries
@@ -253,6 +377,9 @@ Verify that searches are case-insensitive, match formatted values across columns
!!! info "Primary documentation" !!! info "Primary documentation"
- [NiceGUI table documentation](https://nicegui.io/documentation/table) - [NiceGUI table documentation](https://nicegui.io/documentation/table)
- [Quasar QTable documentation](https://quasar.dev/vue-components/table) - [Quasar QTable documentation](https://quasar.dev/vue-components/table)
- [Python aware and naive datetimes](https://docs.python.org/3/library/datetime.html#aware-and-naive-objects)
- [JavaScript `Date.parse()`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/parse)
- [JavaScript `Intl.DateTimeFormat`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat)
- [CSS table layout algorithm](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout) - [CSS table layout algorithm](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/table-layout)
- [CSS width sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/width) - [CSS width sizing](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/width)
@@ -274,7 +401,7 @@ Before accepting a customized table:
1. Verify the target NiceGUI release and its bundled Quasar version. 1. Verify the target NiceGUI release and its bundled Quasar version.
2. Set a primitive, immutable, unique `row_key` before using selection or row-scoped actions. 2. Set a primitive, immutable, unique `row_key` before using selection or row-scoped actions.
3. Use constructor arguments and column definitions before QTable props or slots. 3. Use constructor arguments and column definitions before QTable props or slots.
4. Use `:` only for JavaScript expressions and keep row values serializable. 4. Keep sortable values canonical and serializable; use `format` only for cosmetic text and `sort` only when raw values need a custom comparator.
5. Choose the narrowest named slot and preserve QTable cell or header semantics. 5. Choose the narrowest named slot and preserve QTable cell or header semantics.
6. Recheck sorting, filtering, pagination, selection, and empty states after customization. 6. Recheck sorting, filtering, pagination, selection, and empty states after customization.
7. Check mobile, landscape desktop, and portrait desktop layouts; verify the toolbar remains usable and wide tables scroll without overlapping controls. 7. Check mobile, landscape desktop, and portrait desktop layouts; verify the toolbar remains usable and wide tables scroll without overlapping controls.