public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: yew-devel@lists.proxmox.com
Subject: [PATCH yew-widget-toolkit v3] widget: data table: implement auto column sizing based on sample records
Date: Wed,  7 Oct 2026 10:15:57 +0200	[thread overview]
Message-ID: <20261007081647.947344-1-d.csapak@proxmox.com> (raw)

When a column size is not explicitly given (like "100px" or "15em"), the
default size is implicitly determined by the header text. In cases where
the content should always be shown (like in an action column) and
depends on the theme/density (for padding/width/etc.) this lead to an
bad tradeoff:

Either a fixed size is used and it's sometimes too wide or too narrow,
or some css expression is used that dynamically determines the size.

Both are not ideal, so introduce 'sample records' that the data table
can use to (invisibly) render a set of dummy rows which can dynamically
determine the correct size.

This can be used for e.g. setting an example that should always fit, or
having a fixed set of ActionIcons, etc.

When multiple records are given, the largest one for each column wins.

Since too many rendered records can impact the performance, limit the
considered records and assert the number in debug mode. Reuse
VIRTUAL_SCROLL_TRIGGER as the limit, since that is also the cut-off for
the visible rows.

Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
---
changes from v2:
* limit the amount of sample records to VIRTUAL_SCROLL_TRIGGER
  (and debug assert in create)
* document this limit and make the variable const and public so
  it's visible in the documentation.

 src/widget/data_table/data_table.rs    | 99 ++++++++++++++++++++++++--
 src/widget/data_table/header_widget.rs | 45 ++++++++++++
 src/widget/data_table/mod.rs           |  2 +-
 3 files changed, 140 insertions(+), 6 deletions(-)

diff --git a/src/widget/data_table/data_table.rs b/src/widget/data_table/data_table.rs
index f00d9ab..5add94e 100644
--- a/src/widget/data_table/data_table.rs
+++ b/src/widget/data_table/data_table.rs
@@ -21,10 +21,13 @@ use crate::props::{
 use crate::state::{DataStore, Selection, SelectionObserver};
 use crate::widget::{Column, Container, get_unique_element_id};
 
+use pwt_macros::builder;
+
 use super::{
-    CellConfiguration, DataTableColumn, DataTableHeader, DataTableKeyboardEvent,
-    DataTableMouseEvent, DataTableRow, DataTableRowRenderCallback, HeaderWidget, IndexedHeader,
-    IntoOptionalDataTableRowRenderCallback, create_indexed_header_list,
+    CellConfiguration, DataTableCellRenderArgs, DataTableColumn, DataTableHeader,
+    DataTableKeyboardEvent, DataTableMouseEvent, DataTableRow, DataTableRowRenderCallback,
+    HeaderWidget, IndexedHeader, IntoOptionalDataTableRowRenderCallback,
+    create_indexed_header_list,
 };
 
 pub enum HeaderMsg<T: 'static> {
@@ -109,6 +112,7 @@ pub enum MultiSelectMode {
 /// * `End`: moves focus to the last cell in the row that contains focus.
 #[derive(Properties, Derivative)]
 #[derivative(Clone(bound = ""), PartialEq(bound = ""))]
+#[builder]
 pub struct DataTable<S: DataStore> {
     /// Yew key property.
     #[prop_or_default]
@@ -173,6 +177,21 @@ pub struct DataTable<S: DataStore> {
     #[prop_or(22)]
     pub min_row_height: usize,
 
+    /// Sample records used for sizing the columns.
+    ///
+    /// Default column widths are computed from the header, because the table rows are rendered
+    /// separately (and only partially with virtual scrolling). These records are rendered
+    /// invisibly inside the header, using the column render functions, so content based widths
+    /// like "auto" or "max-content" account for them. For flex widths, they act as minimum width.
+    ///
+    /// To make it easier to handle with enum records (often used by trees), this is a list, so
+    /// multiple variants can be used simultaneously.
+    ///
+    /// NOTE: Only up to [`VIRTUAL_SCROLL_TRIGGER`] records are supported for performance reasons.
+    #[prop_or_default]
+    #[builder]
+    pub sample_records: Vec<S::Record>,
+
     /// Selection object.
     #[prop_or_default]
     pub selection: Option<Selection>,
@@ -240,7 +259,9 @@ impl<S: DataStore> CssPaddingBuilder for DataTable<S> {}
 impl<S: DataStore> CssMarginBuilder for DataTable<S> {}
 impl<S: DataStore> WidgetStyleBuilder for DataTable<S> {}
 
-static VIRTUAL_SCROLL_TRIGGER: usize = 30;
+/// The automatic cut-off for virtual scrolling. When the DataTable can be scrolled
+/// and there are more rows than this limit, virtual scrolling is automatically enabled.
+pub const VIRTUAL_SCROLL_TRIGGER: usize = 30;
 
 impl<S: DataStore> DataTable<S> {
     /// Create a new instance.
@@ -1049,6 +1070,67 @@ impl<S: DataStore> PwtDataTable<S> {
         };
     }
 
+    // Render the cells of the sizing records with the column renderers, wrapped like table cells,
+    // so that they get the same font and padding when the header measures them. The cells of
+    // one column are stacked, so the widest one determines the column width.
+    fn render_column_sizers(&self, props: &DataTable<S>) -> Vec<Option<Html>> {
+        if props.sample_records.is_empty() {
+            return Vec::new();
+        }
+
+        let records: Vec<(Key, &S::Record)> = props
+            .sample_records
+            .iter()
+            .take(VIRTUAL_SCROLL_TRIGGER)
+            .map(|record| (props.store.extract_key(record), record))
+            .collect();
+
+        let mut column_index = 0;
+        self.columns
+            .iter()
+            .enumerate()
+            .map(|(column_num, column)| {
+                if let Some(true) = self.column_hidden.get(column_num) {
+                    return None;
+                }
+
+                let mut sizer = Container::new().class("pwt-datatable-content");
+                for (row_index, (record_key, record)) in records.iter().enumerate() {
+                    let mut args = DataTableCellRenderArgs {
+                        selection: props.selection.clone(),
+                        record: *record,
+                        record_key,
+                        row_index,
+                        column_index,
+                        selected: false,
+                        config: (*self.cell_config).clone(),
+                        is_expanded: false,
+                        is_leaf: true,
+                        level: 0,
+                    };
+
+                    let cell = column.apply_render(&mut args);
+
+                    sizer.add_child(
+                        Container::new()
+                            .class(args.config.class)
+                            .styles(args.config.style)
+                            .style("white-space", "nowrap")
+                            // cell widths get reduced by the border width, see
+                            // render_empty_row_with_sizes
+                            .style(
+                                "border-inline-end",
+                                props.bordered.then_some("1px solid transparent"),
+                            )
+                            .with_child(html! {<div role="none">{cell}</div>}),
+                    );
+                }
+                column_index += 1;
+                Some(sizer.into())
+            })
+            .collect()
+    }
+
     fn init_headers(&mut self, props: &DataTable<S>) {
         let headers = create_indexed_header_list(&props.headers);
 
@@ -1080,6 +1162,12 @@ impl<S: DataStore + 'static> Component for PwtDataTable<S> {
             props.cell_configuration.clone()
         };
 
+        debug_assert!(
+            props.sample_records.len() < VIRTUAL_SCROLL_TRIGGER,
+            "More than {VIRTUAL_SCROLL_TRIGGER} sample records can have negative performance \
+             anything more than that will not be rendered."
+        );
+
         let _store_observer = props
             .store
             .add_listener(ctx.link().callback(|_| Msg::DataChange));
@@ -1764,7 +1852,8 @@ impl<S: DataStore + 'static> Component for PwtDataTable<S> {
                             .focusable(props.header_focusable && props.show_header)
                             .selection_status(self.selection_status)
                             .header_class(header_class)
-                            .reserve_scroll_space(self.scrollbar_size.unwrap_or_default()),
+                            .reserve_scroll_space(self.scrollbar_size.unwrap_or_default())
+                            .column_sizers(self.render_column_sizers(props)),
                     )
                     .into_html_with_ref(self.header_scroll_ref.clone()),
             )
diff --git a/src/widget/data_table/header_widget.rs b/src/widget/data_table/header_widget.rs
index b6f06f6..a588b21 100644
--- a/src/widget/data_table/header_widget.rs
+++ b/src/widget/data_table/header_widget.rs
@@ -43,6 +43,11 @@ pub struct HeaderWidget<T: 'static> {
     pub focusable: bool,
 
     reserve_scroll_space: f64,
+
+    /// Invisible content per column (indexed like the column list), used to size the column
+    /// tracks.
+    #[prop_or_default]
+    pub column_sizers: Vec<Option<Html>>,
 }
 
 impl<T: 'static> HeaderWidget<T> {
@@ -95,6 +100,17 @@ impl<T: 'static> HeaderWidget<T> {
     pub fn set_reserve_scroll_space(&mut self, reserve_scroll_space: f64) {
         self.reserve_scroll_space = reserve_scroll_space;
     }
+
+    /// Builder style method to set the column sizers.
+    pub fn column_sizers(mut self, column_sizers: Vec<Option<Html>>) -> Self {
+        self.set_column_sizers(column_sizers);
+        self
+    }
+
+    /// Method to set the column sizers.
+    pub fn set_column_sizers(&mut self, column_sizers: Vec<Option<Html>>) {
+        self.column_sizers = column_sizers;
+    }
 }
 
 pub enum Msg {
@@ -633,6 +649,35 @@ impl<T: 'static> Component for PwtHeaderWidget<T> {
             &mut header_row,
         );
 
+        // Put the sizers into their column tracks, so that content based track sizes account for
+        // them. They have no height and are invisible, so they do not change the header layout.
+        // Header cells end at row line 10, so use the row after that to avoid interfering with
+        // the auto placed group headers.
+        let mut grid_column = 0;
+        for col_idx in 0..self.state.columns().len() {
+            if self.state.get_column_hidden(col_idx) {
+                continue;
+            }
+            grid_column += 1;
+            if let Some(Some(sizer)) = props.column_sizers.get(col_idx) {
+                header_row.push(
+                    Container::new()
+                        .key(Key::from(format!("sizer-{col_idx}")))
+                        .attribute("role", "none")
+                        .attribute("aria-hidden", "true")
+                        .attribute(
+                            "style",
+                            format!(
+                                "grid-row: 10;grid-column-start: {grid_column};\
+                                height: 0;overflow: clip;visibility: hidden;"
+                            ),
+                        )
+                        .with_child(sizer.clone())
+                        .into(),
+                );
+            }
+        }
+
         let column_count = self.state.visible_column_count();
 
         // add some space at the end to make room for the tables vertical scrollbar
diff --git a/src/widget/data_table/mod.rs b/src/widget/data_table/mod.rs
index 2ab03b6..42b8c21 100644
--- a/src/widget/data_table/mod.rs
+++ b/src/widget/data_table/mod.rs
@@ -45,7 +45,7 @@ mod data_table;
 pub(crate) use data_table::HeaderMsg;
 #[doc(hidden)]
 pub use data_table::PwtDataTable;
-pub use data_table::{DataTable, MultiSelectMode, RowSelectionStatus};
+pub use data_table::{DataTable, MultiSelectMode, RowSelectionStatus, VIRTUAL_SCROLL_TRIGGER};
 
 use yew::prelude::*;
 
-- 
2.47.3





                 reply	other threads:[~2026-10-07  8:16 UTC|newest]

Thread overview: [no followups] expand[flat|nested]  mbox.gz  Atom feed

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261007081647.947344-1-d.csapak@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=yew-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal