Please Stand By


Shpadoinkle-widgets offers a feature-rich table widget. It features:

  • The ability to fully customize the styling

  • Generic handling of turning tabular data into an HTML table

  • Generic handling of data filters

  • Generic handling of sorting data by any column (in ascending or descending order)

  • Built-in and customizable interactions with table headers to sort by a column

  • Generic handling of lazily loaded and lazily populated tables with infinite scroll

All of this is achieved using only a few hundred lines of code, located in the modules Shpadoinkle.Widgets.Table and Shpadoinkle.Widgets.Table.Lazy.

A "lazily loaded table," for our purposes, is one where the data is not all loaded up front but rather is loaded as the user interacts with the table, using an infinite scrolling experience.

A "lazily populated table," for our purposes, is one where the DOM nodes for table rows are not necessarily created up front, but rather may be created on demand as the user scrolls. A lazily populated table may or may not be lazily loaded.

Both lazily loaded tables and lazily populated tables are handled using Shpadoinkle.Widgets.Table.Lazy.

Let’s start with the case of a regular non-lazy table. This case depends on a few concrete types:

Sort represents a sort order (ascending or descending).

SortCol represents a way of sorting the table. It consists of a column and a sort order.

Column and Row are data families. The user of the table widget must instantiate each of these data families for their use case. Column should be an enumerable data type with a finite number of cases, one for each column of the table. Row should be a view model for a table row.

Column and Row are parameterized by a single type a. This type a is a view model for a table, almost. The type a will be supplemented with some data which the table widget uses to maintain its state, in order to create a full view model for a table. This is done by putting a as an element of a tuple.

For a basic table widget, a view model consists of a and a SortCol a. A SortCol a consists of a Column a and a Sort. So a view model for a basic table widget is a tuple (a, SortCol a). For a lazy table widget, there is some more data that gets added onto the view model.

The type a must be an instance of the Tabular type class. This type class explains to the table widget module how to render the table and create the basic user interactions. It has the following pieces:

  • A constraint family Effect a which takes a type of kind * → * (in practice, a monad). This puts the constraints on the monad where event handlers run which those event handlers require in order to do their work. By default, Effect a m is a constraint which requires that m is Applicative.

  • A function toRows :: a → [Row a] which extracts the rows from the table data.

  • A function toFilter :: a → (Row a → Bool) which tells the table widget how to filter rows. This implies that the state of the table filters (if any) must be on a. The default implementation of toFilter does not filter out any rows.

  • A function toCell which tells the table widget how to render the contents of a cell.

  • A function sortTable which tells the table widget how to compare two rows to say which should come first based on the sort order.

  • A view ascendingIcon which tells the table widget how to render an icon indicating that a certain column is the one we are sorting by and the sort is in ascending order. By default this is an arrow pointing upwards rendered in Unicode.

  • A view descendingIcon which tells the table widget how to render an icon indicating that a certain column is the one we are sorting by and the sort is in descending order. By default this is an arrow pointing downwards rendered in Unicode.

  • A function handleSort which gives an action (a continuation) to perform when the user changes the sort order. By default it doesn’t do anyting. In that case, the table widget still knows how to re-sort the rows and it will do so.

The table widget requires some additional information to know how to render the table, which is provided in the form of a Theme. Theme is a data type which is parameterized by m (the monad where event handlers run) and a (the table data type). The Theme can be used to attach arbitrary properties to the <table, <thead>, <tbody>, <th>, <tr>, and <td> elements generated by the table widget.

If you do not require any special properties on the aforementioned elements, then you can create a basic table widget using Shpadoinkle.Widgets.Table.view, which will use a default theme that does not attach any properties to the aforementioned elements beyond those required to make the table widget function with its default behavior. This function expects an a (the table data) and a sort order (a SortCol a) and it gives you a view with the view model type Html m (a, SortCol a).

If you do need to provide a non-default Theme, then you can create a basic table widget using Shpadoinkle.Widgets.Table.viewWith, which expects (in addition to an a and a SortCol a), a Theme m a.

Lazy tables

To create a lazy table, you will need to import both Shpadoinkle.Widgets.Table and Shpadoinkle.Widgets.Table.Lazy. The latter is a special application of the former.

The lazy table module introduces a subclass of Tabular, called LazyTabular. LazyTabular has a method, countRows, which tells the lazy table widget how to know how many rows are in a table. For a basic table widget, we can count the rows by doing length . toRows, but this does not necessarily work for a lazy table widget, because some of the rows might be on the backend (not yet fetched to the client side). It also may be beneficial to avoid computing length . toRows on a lazily populated table where all the rows are in memory, because taking the length of a long list can be an expensive operation. Thus it is up to the user of the lazy table widget to say how to determine the number of rows.

The number of rows is used to set the size of the scroll bar. There may be a very empty row at the end of the table if the number of rows rendered in the DOM is less than the number output by countRows. This is done in order to give the user an accurate general idea of how much data is in the table, so they know that if they scroll down to a certain depth, they may see data, and they can do so by dragging the scroll bar.

The lazy table widget wraps the table view model type a in a data type LazyTable a, in order to hold state which it needs in order to function. This LazyTable data type does not escape into end user code. The LazyTable data type can be regenerated given certain information passed to the lazy table widget along with certain information which must be stored on the end user’s view model. What information this is exactly depends on what kind of lazy table we are talking about.

A lazily populated table (not a lazy loading table) is constructed using the lazyTable function, which outputs a view of type Html m (b, CurrentScrollY). We will get back to b momentarily. The additional information which the lazily populated table needs to put on the user’s view model is CurrentScrollY, which tracks the number of pixels the user has scrolled down in the table.

A lazily loaded table (which may also be a lazily populated table) is constructed using the lazyLoadingTable function, which outputs a view of type Html m (b, CurrentScrollY, RowsLoaded). The additional information which the lazily loaded table needs to put on the user’s view model in addition to CurrentScrollY is RowsLoaded, which tracks the number of rows that have been loaded so far.

Both varieties of lazy table widget need the following data in addition to the data required by the basic table widget:

  • The AssumedTableHeight, which is the number of pixels tall the whole table is. This is either the height of the table body, or the height of the container, depending on the setting of LazyTableScrollConfig.

  • The AssumedRowHeight, which is the number of pixels tall a row of data is. This is assumed to be the same for all rows, so that the trick of extending the scroll bar can work.

  • The LazyTableScrollConfig. This tells the widget whether you want the container of the table to scroll, or the body of the table. If you let the container scroll, then the table headers will scroll out of view. If you let the body scroll, the the table headers will not scroll out of view. In addition, the LazyTableScrollConfig supplies the table widget with a Debounce. The Debounce lets the table widget debounce the scroll events at an approriate tempo (which you can find for your app using testing). Debouncing the scroll events is necessary in order to avoid performance problems when scrolling the table.

  • The container. For a lazily populated table, it is of type Html m ((a, SortCol a), CurrentScrollY) → Html m ((b, SortCol a), CurrentScrollY). For a lazy loading table, it is of type Html m ((b, SortCol a), CurrentScrollY, RowsLoaded). The container wraps around the <table> element. If you do not need a container, then you can set it to id. It is provided so that it can be the thing which scrolls in the lazy table interaction. This is where b comes in the type of the resulting view. The container is allowed to change the type of the view model, though it is not allowed to remove the data which the basic table widget and the lazy table widget require.

  • The CurrentScrollY must also be supplied.

For a lazy loading table, the following additional data must be supplied:

  • The RowsLoaded.

  • The Paginator. The Paginator tells the table widget how to load more data.

Paginator a is a newtype of forall m. ( Applicative m, Effect a m ) ⇒ a → SortCol a → Page → m a. Page is a data type representing a page of data, consisting of an Offset (a zero-based index into the whole list of rows, after applying the filters and sort order), and a Length (a number of rows). Given the current table view model a, and the sort order and a page, the Paginator must return a new table view model which includes the data in the given Page (as well as any data that is already there which is outside of the given Page).

The lazy loading table widget handles knowing which pages of data to load when as the end user scrolls, without reloading data that is already there. When the end user changes the sort order, the widget knows to fetch a page of data which has an offset of zero and goes as far as the end user has scrolled. It is up to the user of the widget to load new data when the end user changes the filters, because the controls for any filters are not part of the table widget.