///|
/// One typed column in a black-box `data_table`.
///
/// `value` supplies the searchable and sortable text. `render`, when present,
/// only changes the visual cell content, so formatting never breaks table
/// operations.
struct DataTableColumn[T] {
key : String
label : String
value : (T) -> String
render : ((T) -> @html.Html)?
sortable : Bool
searchable : Bool
hideable : Bool
numeric : Bool
}
///|
priv struct DataTableModel {
query : String
sort_key : String?
sort_descending : Bool
page : Int
selected : Array[String]
hidden_columns : Array[String]
columns_open : Bool
} derive(Eq)
///|
priv enum DataTableMsg {
DataTableSetQuery(String)
DataTableSort(String)
DataTableSetPage(Int)
DataTableSetSelection(Array[String])
DataTableToggleColumn(String)
DataTableToggleColumns
DataTableCloseColumns
}
///|
#cfg(target="js")
priv suberror DataTableColumnsSubscription {
WatchDataTableColumns(String, @cmd.Cmd)
}
///|
const DataTableRootStyle : String = "position:relative;display:flex;width:100%;min-width:0;flex-direction:column;gap:1rem;color:var(--rui-foreground,oklch(0.145 0 0))"
///|
const DataTableToolbarStyle : String = "position:relative;z-index:2;display:flex;width:100%;min-width:0;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem"
///|
const DataTableSearchStyle : String = "width:min(20rem,100%)"
///|
const DataTableColumnsWrapStyle : String = "position:relative;display:inline-flex;margin-inline-start:auto"
///|
const DataTableColumnsMenuStyle : String = "position:absolute;inset-block-start:calc(100% + 0.375rem);inset-inline-end:0;z-index:20;display:flex;min-width:11rem;flex-direction:column;gap:0.125rem;border:1px solid color-mix(in oklab,var(--rui-foreground,oklch(0.145 0 0)) 10%,transparent);border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem);background:var(--rui-popover,oklch(1 0 0));padding:0.25rem;color:var(--rui-popover-foreground,oklch(0.145 0 0));box-shadow:0 10px 15px -3px rgb(0 0 0 / 0.1),0 4px 6px -4px rgb(0 0 0 / 0.1);transform-origin:top right;transition:opacity 100ms ease,transform 100ms ease,visibility 100ms ease"
///|
const DataTableColumnsMenuOpenStyle : String = "visibility:visible;opacity:1;pointer-events:auto;transform:scale(1)"
///|
const DataTableColumnsMenuClosedStyle : String = "visibility:hidden;opacity:0;pointer-events:none;transform:scale(0.96)"
///|
const DataTableColumnOptionStyle : String = "display:flex;width:100%;min-height:2rem;align-items:center;gap:0.5rem;border-radius:calc(var(--rui-radius,0.625rem) - 0.25rem);padding:0.375rem 0.5rem;font-size:0.875rem;line-height:1.25rem;text-transform:capitalize;white-space:nowrap"
///|
const DataTableSurfaceStyle : String = "overflow:hidden;border:1px solid var(--rui-border,oklch(0.922 0 0));border-radius:calc(var(--rui-radius,0.625rem) - 0.125rem)"
///|
const DataTableSortButtonStyle : String = "height:2rem;margin-inline-start:-0.25rem;padding-inline:0.5rem"
///|
const DataTableSortIconStyle : String = "display:inline-flex;width:1rem;height:1rem;align-items:center;justify-content:center;color:var(--rui-muted-foreground,oklch(0.556 0 0))"
///|
const DataTableEmptyStyle : String = "height:7rem;color:var(--rui-muted-foreground,oklch(0.556 0 0));text-align:center"
///|
const DataTableFooterStyle : String = "display:flex;width:100%;min-width:0;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:0.75rem"
///|
const DataTableSummaryStyle : String = "color:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:0.8125rem;line-height:1.25rem"
///|
const DataTablePagerStyle : String = "display:flex;align-items:center;gap:0.5rem"
///|
const DataTablePageLabelStyle : String = "min-width:5.5rem;color:var(--rui-muted-foreground,oklch(0.556 0 0));font-size:0.8125rem;line-height:1.25rem;text-align:center"
///|
/// Construct a typed column for `data_table`.
pub fn[T] data_table_column(
key~ : String,
label~ : String,
value~ : (T) -> String,
render? : (T) -> @html.Html,
sortable? : Bool = true,
searchable? : Bool = true,
hideable? : Bool = true,
numeric? : Bool = false,
) -> DataTableColumn[T] {
{ key, label, value, render, sortable, searchable, hideable, numeric }
}
///|
fn data_table_contains(values : Array[String], value : String) -> Bool {
for current in values {
if current == value {
return true
}
}
false
}
///|
fn data_table_remove(values : Array[String], value : String) -> Array[String] {
let result : Array[String] = []
for current in values {
if current != value {
result.push(current)
}
}
result
}
///|
fn data_table_toggle(values : Array[String], value : String) -> Array[String] {
if data_table_contains(values, value) {
data_table_remove(values, value)
} else {
let result = values.copy()
result.push(value)
result
}
}
///|
fn[T] data_table_column_index(
columns : Array[DataTableColumn[T]],
key : String,
) -> Int {
for index, column in columns {
if column.key == key {
return index
}
}
-1
}
///|
fn[T] data_table_row_matches(
row : T,
columns : Array[DataTableColumn[T]],
query : String,
) -> Bool {
if query == "" {
return true
}
let needle = query.to_lower()
for column in columns {
if column.searchable && (column.value)(row).to_lower().contains(needle) {
return true
}
}
false
}
///|
fn[T] data_table_visible_indices(
rows : Array[T],
columns : Array[DataTableColumn[T]],
model : DataTableModel,
) -> Array[Int] {
let indices : Array[Int] = []
for index, row in rows {
if data_table_row_matches(row, columns, model.query) {
indices.push(index)
}
}
if model.sort_key is Some(key) {
let column_index = data_table_column_index(columns, key)
if column_index >= 0 && columns[column_index].sortable {
let column = columns[column_index]
indices.sort_by((left, right) => {
let order = (column.value)(rows[left]).compare(
(column.value)(rows[right]),
)
if model.sort_descending {
-order
} else {
order
}
})
}
}
indices
}
///|
fn data_table_page_count(row_count : Int, page_size : Int) -> Int {
if row_count == 0 {
1
} else {
(row_count + page_size - 1) / page_size
}
}
///|
fn data_table_effective_page(page : Int, page_count : Int) -> Int {
if page < 0 {
0
} else if page >= page_count {
page_count - 1
} else {
page
}
}
///|
fn data_table_command(
emit : @cmd.Emit[DataTableMsg]?,
message : DataTableMsg,
) -> @cmd.Cmd? {
if emit is Some(emit) {
Some(emit(message))
} else {
None
}
}
///|
fn data_table_value_emit(
emit : @cmd.Emit[DataTableMsg]?,
wrap : (String) -> DataTableMsg,
) -> @cmd.Emit[String]? {
if emit is Some(emit) {
Some(emit.map(wrap))
} else {
None
}
}
///|
fn data_table_bool_emit(
emit : @cmd.Emit[DataTableMsg]?,
wrap : (Bool) -> DataTableMsg,
) -> @cmd.Emit[Bool]? {
if emit is Some(emit) {
Some(emit.map(wrap))
} else {
None
}
}
///|
#cfg(target="js")
fn data_table_columns_contains_related_target(
id : String,
event : @dom.FocusEvent,
) -> Bool {
guard ui_element_by_id(id + "-columns") is Some(columns) else { return false }
event.related_target() is Some(related) &&
ui_element_contains_target(columns, related)
}
///|
#cfg(target="js")
fn data_table_focus_columns_trigger(id : String) -> Unit {
guard ui_element_by_id(id + "-columns") is Some(columns) else { return }
guard ui_find_descendant_by_attribute(
columns, "data-slot", "data-table-columns-trigger",
)
is Some(trigger) else {
return
}
ui_focus_element(trigger)
}
///|
#cfg(target="js")
fn data_table_columns_event_is_outside(id : String, event : @dom.Event) -> Bool {
if ui_element_by_id(id + "-columns") is Some(columns) {
!ui_element_contains_target(columns, event.target())
} else {
false
}
}
///|
#cfg(target="js")
fn data_table_add_columns_listener(
event : String,
listener : @dom.Listener,
) -> Unit {
@dom.document().add_event_listener_with_options(event, listener, capture=true)
}
///|
#cfg(target="js")
fn data_table_remove_columns_listener(
event : String,
listener : @dom.Listener,
) -> Unit {
@dom.document().remove_event_listener_with_options(
event,
listener,
capture=true,
)
}
///|
#cfg(target="js")
fn data_table_columns_subscription_loader(
payload : Error,
scheduler : &@cmd.Scheduler,
) -> @sub.RunningSub? {
match payload {
WatchDataTableColumns(id, close) => {
let mut close_command = close
let listener : @dom.Listener = event => {
if data_table_columns_event_is_outside(id, event) {
scheduler.add(close_command)
}
}
data_table_add_columns_listener("pointerdown", listener)
data_table_add_columns_listener("focusin", listener)
Some({
unload: _ => {
data_table_remove_columns_listener("pointerdown", listener)
data_table_remove_columns_listener("focusin", listener)
},
update_tagger: payload => {
if payload is WatchDataTableColumns(_, next_close) {
close_command = next_close
}
},
})
}
_ => None
}
}
///|
#cfg(target="js")
fn data_table_columns_dismiss_subscription(
id : String,
close : @cmd.Cmd,
) -> @sub.Sub {
@sub.custom_sub(
"data-table-columns-dismiss:\{id}",
@sub.Local,
WatchDataTableColumns(id, close),
@sub.SubLoader(data_table_columns_subscription_loader),
)
}
///|
#cfg(target="js")
fn data_table_bind_columns_control_dismiss(
attrs : @html.Attrs,
id : String,
close : @cmd.Cmd?,
open : Bool,
) -> Unit {
if open && close is Some(close) {
ignore(
attrs
.on_blur(event => {
if data_table_columns_contains_related_target(id, event) {
@cmd.none
} else {
close
}
})
.on_keydown(event => {
if event.key() == "Escape" {
event.prevent_default()
event.stop_propagation()
data_table_focus_columns_trigger(id)
close
} else {
@cmd.none
}
}),
)
}
}
///|
#cfg(not(target="js"))
fn data_table_bind_columns_control_dismiss(
attrs : @html.Attrs,
id : String,
close : @cmd.Cmd?,
open : Bool,
) -> Unit {
ignore((attrs, id, close, open))
}
///|
fn[T] data_table_render_cell(
column : DataTableColumn[T],
row : T,
) -> @html.Html {
if column.render is Some(render) {
render(row)
} else {
@html.text((column.value)(row))
}
}
///|
fn[T] render_data_table(
id : String,
rows : Array[T],
columns : Array[DataTableColumn[T]],
row_key : (T) -> String,
model : DataTableModel,
page_size : Int,
searchable : Bool,
selectable : Bool,
show_columns : Bool,
search_placeholder : String,
empty_text : String,
aria_label : String,
emit : @cmd.Emit[DataTableMsg]?,
class : String?,
title : String?,
attrs : @html.Attrs?,
style : Array[String],
) -> @html.Html {
let visible_indices = data_table_visible_indices(rows, columns, model)
let page_count = data_table_page_count(visible_indices.length(), page_size)
let page = data_table_effective_page(model.page, page_count)
let page_start = page * page_size
let page_end = if page_start + page_size < visible_indices.length() {
page_start + page_size
} else {
visible_indices.length()
}
let page_keys : Array[String] = []
for position, row_index in visible_indices {
if position >= page_start && position < page_end {
page_keys.push(row_key(rows[row_index]))
}
}
let selected_on_page = page_keys.fold(init=0, (count, key) => {
if data_table_contains(model.selected, key) {
count + 1
} else {
count
}
})
let selected_visible = visible_indices.fold(init=0, (count, row_index) => {
if data_table_contains(model.selected, row_key(rows[row_index])) {
count + 1
} else {
count
}
})
let all_page_selected = page_keys.length() > 0 &&
selected_on_page == page_keys.length()
let some_page_selected = selected_on_page > 0 && !all_page_selected
let header_cells : Array[@html.Html] = []
if selectable {
let next_selection = if all_page_selected {
model.selected.filter(key => !data_table_contains(page_keys, key))
} else {
let selected = model.selected.copy()
for key in page_keys {
if !data_table_contains(selected, key) {
selected.push(key)
}
}
selected
}
let on_checked_change = data_table_bool_emit(emit, _ => {
DataTableSetSelection(next_selection)
})
header_cells.push(
table_head(
style=["width:2.75rem;padding-inline:0.75rem"],
render_checkbox(
checked=all_page_selected,
indeterminate=some_page_selected,
disabled=page_keys.length() == 0,
aria_label="Select all rows on this page",
on_checked_change?,
),
),
)
}
for column in columns {
if !data_table_contains(model.hidden_columns, column.key) {
let active = model.sort_key == Some(column.key)
let sort_attrs = @html.Attrs::build()
.data_set("slot", "data-table-sort-trigger")
.data_set("column", column.key)
let on_click = data_table_command(emit, DataTableSort(column.key))
let head_attrs = @html.Attrs::build()
if active {
ignore(
head_attrs.aria_sort(
if model.sort_descending {
"descending"
} else {
"ascending"
},
),
)
} else if column.sortable {
ignore(head_attrs.aria_sort("none"))
}
let heading = if column.sortable {
button(
variant=Ghost,
size=Sm,
on_click?,
attrs=sort_attrs,
style=[DataTableSortButtonStyle],
slot="data-table-sort-trigger",
[
@html.span(column.label),
@html.span(
attrs=@html.Attrs::build()
.data_set("slot", "data-table-sort-icon")
.aria_hidden("true"),
style=[
DataTableSortIconStyle,
if active {
"opacity:1"
} else {
"opacity:0.35"
},
],
if active && model.sort_descending {
ui_chevron_down_icon()
} else {
ui_chevron_up_icon()
},
),
],
)
} else {
@html.text(column.label)
}
header_cells.push(
table_head(
attrs=head_attrs,
style=[if column.numeric { "text-align:end" } else { "" }],
heading,
),
)
}
}
let body_rows : Array[@html.Html] = []
for position, row_index in visible_indices {
if position >= page_start && position < page_end {
let row = rows[row_index]
let key = row_key(row)
let selected = data_table_contains(model.selected, key)
let cells : Array[@html.Html] = []
if selectable {
let next = if selected {
data_table_remove(model.selected, key)
} else {
let selected = model.selected.copy()
selected.push(key)
selected
}
let on_checked_change = data_table_bool_emit(emit, _ => {
DataTableSetSelection(next)
})
cells.push(
table_cell(
style=["width:2.75rem;padding-inline:0.75rem"],
render_checkbox(
checked=selected,
aria_label="Select row \{key}",
on_checked_change?,
),
),
)
}
for column in columns {
if !data_table_contains(model.hidden_columns, column.key) {
cells.push(
table_cell(
style=[if column.numeric { "text-align:end" } else { "" }],
data_table_render_cell(column, row),
),
)
}
}
body_rows.push(
table_row(
selected~,
attrs=@html.Attrs::build()
.data_set("data-table-row", "")
.data_set("row-key", key),
cells,
),
)
}
}
if body_rows.length() == 0 {
let visible_column_count = columns.fold(init=0, (count, column) => {
if data_table_contains(model.hidden_columns, column.key) {
count
} else {
count + 1
}
})
let colspan = visible_column_count + (if selectable { 1 } else { 0 })
body_rows.push(
table_row(table_cell(colspan~, style=[DataTableEmptyStyle], empty_text)),
)
}
let toolbar_children : Array[@html.Html] = []
if searchable {
let on_input = data_table_value_emit(emit, query => DataTableSetQuery(query))
toolbar_children.push(
input(
input_type=@html.Search,
value=model.query,
placeholder=search_placeholder,
on_input?,
attrs=@html.Attrs::build().aria_label("Filter rows"),
style=[DataTableSearchStyle],
slot="data-table-search",
),
)
}
if show_columns {
let menu_items : Array[@html.Html] = []
let close_columns = data_table_command(emit, DataTableCloseColumns)
for column in columns {
if column.hideable {
let visible = !data_table_contains(model.hidden_columns, column.key)
let on_checked_change = data_table_bool_emit(emit, _ => {
DataTableToggleColumn(column.key)
})
let option_attrs = @html.Attrs::build()
data_table_bind_columns_control_dismiss(
option_attrs,
id,
close_columns,
model.columns_open,
)
menu_items.push(
@html.label(style=[DataTableColumnOptionStyle], [
render_checkbox(
checked=visible,
aria_label="Toggle \{column.label} column",
on_checked_change?,
attrs=option_attrs,
),
@html.span(column.label),
]),
)
}
}
let toggle_columns = data_table_command(emit, DataTableToggleColumns)
let trigger_attrs = @html.Attrs::build()
.data_set("slot", "data-table-columns-trigger")
.aria_haspopup("menu")
.aria_expanded(ui_bool(model.columns_open))
.aria_controls("\{id}-columns-menu")
data_table_bind_columns_control_dismiss(
trigger_attrs,
id,
close_columns,
model.columns_open,
)
toolbar_children.push(
@html.div(
id="\{id}-columns",
attrs=@html.Attrs::build()
.data_set("slot", "data-table-columns-wrap")
.data_set("state", if model.columns_open { "open" } else { "closed" }),
style=[DataTableColumnsWrapStyle],
[
button(
variant=Outline,
size=Sm,
on_click?=toggle_columns,
attrs=trigger_attrs,
slot="data-table-columns-trigger",
[@html.span("Columns"), ui_chevron_down_icon()],
),
@html.div(
id="\{id}-columns-menu",
attrs=@html.Attrs::build()
.data_set("slot", "data-table-columns-menu")
.data_set(
"state",
if model.columns_open {
"open"
} else {
"closed"
},
)
.role("menu")
.aria_hidden(ui_bool(!model.columns_open)),
style=[
DataTableColumnsMenuStyle,
if model.columns_open {
DataTableColumnsMenuOpenStyle
} else {
DataTableColumnsMenuClosedStyle
},
],
menu_items,
),
],
),
)
}
let previous = data_table_command(emit, DataTableSetPage(page - 1))
let next = data_table_command(emit, DataTableSetPage(page + 1))
let root_attrs = ui_attrs(attrs)
.data_set("slot", "data-table")
.data_set("page", "\{page + 1}")
.data_set("page-count", "\{page_count}")
.data_set("row-count", "\{visible_indices.length()}")
@html.div(
id~,
class?,
title?,
attrs=root_attrs,
style=ui_styles(
[UiBoxSizing, UiFontSans, UiTextRendering, DataTableRootStyle],
style,
),
[
if toolbar_children.length() > 0 {
@html.div(
attrs=@html.Attrs::build().data_set("slot", "data-table-toolbar"),
style=[DataTableToolbarStyle],
toolbar_children,
)
} else {
@html.nothing
},
@html.div(
attrs=@html.Attrs::build().data_set("slot", "data-table-surface"),
style=[DataTableSurfaceStyle],
table(attrs=@html.Attrs::build().aria_label(aria_label), [
table_header(table_row(header_cells)),
table_body(body_rows),
]),
),
@html.div(
attrs=@html.Attrs::build().data_set("slot", "data-table-footer"),
style=[DataTableFooterStyle],
[
@html.div(
attrs=@html.Attrs::build().data_set(
"slot", "data-table-selection-summary",
),
style=[DataTableSummaryStyle],
if selectable {
"\{selected_visible} of \{visible_indices.length()} row(s) selected."
} else {
"\{visible_indices.length()} row(s)."
},
),
@html.div(style=[DataTablePagerStyle], [
button(
variant=Outline,
size=Sm,
disabled=page == 0,
on_click?=previous,
attrs=@html.Attrs::build().data_set("slot", "data-table-previous"),
slot="data-table-previous",
"Previous",
),
@html.span(
attrs=@html.Attrs::build()
.data_set("slot", "data-table-page-label")
.aria_live("polite"),
style=[DataTablePageLabelStyle],
"Page \{page + 1} of \{page_count}",
),
button(
variant=Outline,
size=Sm,
disabled=page >= page_count - 1,
on_click?=next,
attrs=@html.Attrs::build().data_set("slot", "data-table-next"),
slot="data-table-next",
"Next",
),
]),
],
),
],
)
}
///|
fn data_table_update(
message : DataTableMsg,
model : DataTableModel,
) -> DataTableModel {
match message {
DataTableSetQuery(query) => { ..model, query, page: 0 }
DataTableSort(key) =>
if model.sort_key == Some(key) {
{ ..model, sort_descending: !model.sort_descending, page: 0 }
} else {
{ ..model, sort_key: Some(key), sort_descending: false, page: 0 }
}
DataTableSetPage(page) => { ..model, page, }
DataTableSetSelection(selected) => { ..model, selected, }
DataTableToggleColumn(key) =>
{ ..model, hidden_columns: data_table_toggle(model.hidden_columns, key) }
DataTableToggleColumns => { ..model, columns_open: !model.columns_open }
DataTableCloseColumns => { ..model, columns_open: false }
}
}
///|
fn data_table_initial_model(
default_sort_key : String?,
default_sort_descending : Bool,
default_selected : Array[String],
default_hidden_columns : Array[String],
) -> DataTableModel {
{
query: "",
sort_key: default_sort_key,
sort_descending: default_sort_descending,
page: 0,
selected: default_selected.copy(),
hidden_columns: default_hidden_columns.copy(),
columns_open: false,
}
}
///|
/// Build a typed, self-contained data table with local filter, sort,
/// pagination, row-selection, and column-visibility state.
#cfg(target="js")
pub fn[T] data_table(
id~ : String,
rows~ : Array[T],
columns~ : Array[DataTableColumn[T]],
row_key~ : (T) -> String,
page_size? : Int = 5,
searchable? : Bool = true,
selectable? : Bool = true,
show_columns? : Bool = true,
search_placeholder? : String = "Filter rows…",
empty_text? : String = "No results.",
aria_label? : String = "Data table",
default_sort_key? : String,
default_sort_descending? : Bool = false,
default_selected? : Array[String] = [],
default_hidden_columns? : Array[String] = [],
on_selection_change? : @cmd.Emit[Array[String]],
on_query_change? : @cmd.Emit[String],
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @rabbita.Val[@html.Html] {
let initial = data_table_initial_model(
default_sort_key, default_sort_descending, default_selected, default_hidden_columns,
)
let page_size = if page_size < 1 { 1 } else { page_size }
let (model, emit) = @rabbita.create_state(
initial,
update=fn(_, message, current) {
let next = data_table_update(message, current)
let commands : Array[@cmd.Cmd] = []
match message {
DataTableSetSelection(_) =>
if on_selection_change is Some(notify) {
commands.push(notify(next.selected))
}
DataTableSetQuery(_) =>
if on_query_change is Some(notify) {
commands.push(notify(next.query))
}
_ => ()
}
(next, @cmd.batch(commands))
},
subscriptions=(emit, current) => {
if show_columns && current.columns_open {
data_table_columns_dismiss_subscription(id, emit(DataTableCloseColumns))
} else {
@sub.none
}
},
)
model.view(model => {
render_data_table(
id,
rows,
columns,
row_key,
model,
page_size,
searchable,
selectable,
show_columns,
search_placeholder,
empty_text,
aria_label,
Some(emit),
class,
title,
attrs,
style,
)
})
}
///|
/// Native/SSR fallback: render the requested initial table state as a
/// constant incremental value.
#cfg(not(target="js"))
pub fn[T] data_table(
id~ : String,
rows~ : Array[T],
columns~ : Array[DataTableColumn[T]],
row_key~ : (T) -> String,
page_size? : Int = 5,
searchable? : Bool = true,
selectable? : Bool = true,
show_columns? : Bool = true,
search_placeholder? : String = "Filter rows…",
empty_text? : String = "No results.",
aria_label? : String = "Data table",
default_sort_key? : String,
default_sort_descending? : Bool = false,
default_selected? : Array[String] = [],
default_hidden_columns? : Array[String] = [],
on_selection_change? : @cmd.Emit[Array[String]],
on_query_change? : @cmd.Emit[String],
class? : String,
title? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @rabbita.Val[@html.Html] {
ignore((on_selection_change, on_query_change))
let page_size = if page_size < 1 { 1 } else { page_size }
@rabbita.Val::constant(
render_data_table(
id,
rows,
columns,
row_key,
data_table_initial_model(
default_sort_key, default_sort_descending, default_selected, default_hidden_columns,
),
page_size,
searchable,
selectable,
show_columns,
search_placeholder,
empty_text,
aria_label,
None,
class,
title,
attrs,
style,
),
)
}