///|
/// APG Table Pattern
/// https://www.w3.org/WAI/ARIA/apg/patterns/table/
///
/// A table is a widget that presents content as a two-dimensional grid.
/// Tables can include sortable columns, selectable rows, and keyboard navigation.
///
/// Keyboard Interaction (Grid navigation):
/// - Right Arrow: Move to next cell in row
/// - Left Arrow: Move to previous cell in row
/// - Down Arrow: Move to same column in next row
/// - Up Arrow: Move to same column in previous row
/// - Home: Move to first cell in row
/// - End: Move to last cell in row
/// - Ctrl+Home: Move to first cell in table
/// - Ctrl+End: Move to last cell in table
/// - PageDown: Move down by viewport
/// - PageUp: Move up by viewport
///
/// ARIA:
/// - role="table" (or native )
/// - role="rowgroup" for , , 
/// - role="row" for 
/// - role="columnheader" for 
in header /// - role="rowheader" for in row /// - role="cell" for /// - aria-sort on sortable columns (ascending, descending, none) /// - aria-selected on selectable rows ///| /// Sort direction for sortable columns pub(all) enum SortDirection { None Ascending Descending } ///| /// Column definition pub(all) struct TableColumn { id : String label : String sortable : Bool header_scope : String // "col" or "row" } ///| /// Create a column definition pub fn table_column( id : String, label : String, sortable? : Bool, ) -> TableColumn { { id, label, sortable: sortable.unwrap_or(false), header_scope: "col" } } ///| /// Row definition for data tables pub(all) struct TableRow { id : String cells : Array[String] disabled : Bool } ///| /// Create a row definition pub fn table_row( id : String, cells : Array[String], disabled? : Bool, ) -> TableRow { { id, cells, disabled: disabled.unwrap_or(false) } } ///| /// Create a table container element. /// /// Parameters: /// - id: Table ID /// - aria_label: Accessible label /// - aria_labelledby: ID of labelling element /// - aria_describedby: ID of description element /// - children: Table content (thead, tbody, etc.) pub fn[E] table( id? : String, aria_label? : String, aria_labelledby? : String, aria_describedby? : String, children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [] if id is Some(i) { attrs.push(("id", @luna.attr_static(i))) } if aria_label is Some(label) { attrs.push(("aria-label", @luna.attr_static(label))) } if aria_labelledby is Some(lid) { attrs.push(("aria-labelledby", @luna.attr_static(lid))) } if aria_describedby is Some(did) { attrs.push(("aria-describedby", @luna.attr_static(did))) } @luna.h("table", attrs, children) } ///| /// Create a table head section. pub fn[E] thead( children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { @luna.h("thead", [], children) } ///| /// Create a table body section. pub fn[E] tbody( children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { @luna.h("tbody", [], children) } ///| /// Create a table footer section. pub fn[E] tfoot( children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { @luna.h("tfoot", [], children) } ///| /// Create a table row element. /// /// Parameters: /// - id: Row ID /// - selected: Whether row is selected /// - on_click: Click handler /// - on_keydown: Keydown handler /// - children: Cell elements pub fn[E] tr( id? : String, selected? : Bool, on_click? : @luna.EventHandler[E], on_keydown? : @luna.EventHandler[E], children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [] if id is Some(i) { attrs.push(("id", @luna.attr_static(i))) } if selected is Some(true) { attrs.push(("aria-selected", @luna.attr_static("true"))) } else if selected is Some(false) { attrs.push(("aria-selected", @luna.attr_static("false"))) } if on_click is Some(handler) { attrs.push(("click", @luna.attr_handler(handler))) } if on_keydown is Some(handler) { attrs.push(("keydown", @luna.attr_handler(handler))) } @luna.h("tr", attrs, children) } ///| /// Create a dynamic table row with signal-based selection. pub fn[E] tr_dyn( id : String, selected_row : @resource.Signal[String], on_click? : @luna.EventHandler[E], on_keydown? : @luna.EventHandler[E], children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [ ("id", @luna.attr_static(id)), ( "aria-selected", @luna.attr_dynamic(fn() { if selected_row.get() == id { "true" } else { "false" } }), ), ] if on_click is Some(handler) { attrs.push(("click", @luna.attr_handler(handler))) } if on_keydown is Some(handler) { attrs.push(("keydown", @luna.attr_handler(handler))) } @luna.h("tr", attrs, children) } ///| /// Create a column header cell. /// /// Parameters: /// - id: Header ID /// - scope: Scope (col, row, colgroup, rowgroup) /// - sort: Sort direction (for sortable columns) /// - on_click: Click handler (for sortable columns) /// - children: Header content pub fn[E] th( id? : String, scope? : String, sort? : SortDirection, on_click? : @luna.EventHandler[E], children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [] if id is Some(i) { attrs.push(("id", @luna.attr_static(i))) } if scope is Some(s) { attrs.push(("scope", @luna.attr_static(s))) } if sort is Some(Ascending) { attrs.push(("aria-sort", @luna.attr_static("ascending"))) } else if sort is Some(Descending) { attrs.push(("aria-sort", @luna.attr_static("descending"))) } else if sort is Some(None) { attrs.push(("aria-sort", @luna.attr_static("none"))) } if on_click is Some(handler) { attrs.push(("click", @luna.attr_handler(handler))) } @luna.h("th", attrs, children) } ///| /// Create a dynamic column header with signal-based sort state. pub fn[E] th_dyn( id : String, scope : String, sort_column : @resource.Signal[String], sort_direction : @resource.Signal[SortDirection], on_click? : @luna.EventHandler[E], children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [ ("id", @luna.attr_static(id)), ("scope", @luna.attr_static(scope)), ( "aria-sort", @luna.attr_dynamic(fn() { if sort_column.get() == id { match sort_direction.get() { Ascending => "ascending" Descending => "descending" None => "none" } } else { "none" } }), ), ] if on_click is Some(handler) { attrs.push(("click", @luna.attr_handler(handler))) } @luna.h("th", attrs, children) } ///| /// Create a data cell. /// /// Parameters: /// - id: Cell ID /// - headers: Space-separated list of header IDs /// - children: Cell content pub fn[E] td( id? : String, headers? : String, children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [] if id is Some(i) { attrs.push(("id", @luna.attr_static(i))) } if headers is Some(h) { attrs.push(("headers", @luna.attr_static(h))) } @luna.h("td", attrs, children) } ///| /// Create a table caption. pub fn[E] caption( id? : String, children : Array[@luna.Node[E, String]], ) -> @luna.Node[E, String] { let attrs : Array[(String, @luna.Attr[E, String])] = [] if id is Some(i) { attrs.push(("id", @luna.attr_static(i))) } @luna.h("caption", attrs, children) } ///| /// Create a table keyboard handler for grid navigation. /// Handles arrow keys, Home/End, PageUp/PageDown. pub fn make_table_handler( row_count : Int, col_count : Int, current_row : () -> Int, current_col : () -> Int, on_change : (Int, Int) -> Unit, focus_cell : (Int, Int) -> Unit, page_size? : Int, ) -> @luna.EventHandler[@js.Any] { let pg_size = page_size.unwrap_or(10) @luna.handler(fn(e : @js.Any) { let key : String = e._get("key").cast() let ctrl : Bool = e._get("ctrlKey").cast() let row = current_row() let col = current_col() let new_pos : (Int, Int)? = match key { "ArrowRight" => if col < col_count - 1 { Some((row, col + 1)) } else { None } "ArrowLeft" => if col > 0 { Some((row, col - 1)) } else { None } "ArrowDown" => if row < row_count - 1 { Some((row + 1, col)) } else { None } "ArrowUp" => if row > 0 { Some((row - 1, col)) } else { None } "Home" => if ctrl { Some((0, 0)) } else { Some((row, 0)) } "End" => if ctrl { Some((row_count - 1, col_count - 1)) } else { Some((row, col_count - 1)) } "PageDown" => { let new_row = if row + pg_size >= row_count { row_count - 1 } else { row + pg_size } Some((new_row, col)) } "PageUp" => { let new_row = if row - pg_size < 0 { 0 } else { row - pg_size } Some((new_row, col)) } _ => None } if new_pos is Some((new_row, new_col)) { if new_row != row || new_col != col { let _ = e._call("preventDefault", []) on_change(new_row, new_col) focus_cell(new_row, new_col) } } }) } ///| /// Create a sortable table header click handler. /// Toggles between ascending, descending, and none. pub fn make_sort_handler( column_id : String, current_sort_column : () -> String, current_sort_direction : () -> SortDirection, on_sort : (String, SortDirection) -> Unit, ) -> @luna.EventHandler[@js.Any] { @luna.handler(fn(_ : @js.Any) { let curr_col = current_sort_column() let curr_dir = current_sort_direction() let new_dir = if curr_col == column_id { match curr_dir { None => Ascending Ascending => Descending Descending => None } } else { Ascending } on_sort(column_id, new_dir) }) } ///| /// Create a complete interactive data table. /// /// Parameters: /// - id: Table ID /// - columns: Column definitions /// - rows: Row data /// - aria_label: Accessible label /// - selectable: Whether rows are selectable /// - sortable: Whether columns are sortable /// - on_row_select: Called when a row is selected /// - on_sort: Called when sort changes pub fn table_interactive( id : String, columns : Array[TableColumn], rows : Array[TableRow], aria_label? : String, selectable? : Bool, on_row_select? : (String) -> Unit, on_sort? : (String, SortDirection) -> Unit, ) -> @luna.Node[@js.Any, String] { let is_selectable = selectable.unwrap_or(false) let selected_row = @resource.signal("") let sort_column = @resource.signal("") let sort_direction : @resource.Signal[SortDirection] = @resource.signal(None) let focused_row = @resource.signal(0) let focused_col = @resource.signal(0) let row_count = rows.length() let col_count = columns.length() // Focus helper let focus_cell = fn(row : Int, col : Int) { let cell_id = id + "-cell-" + row.to_string() + "-" + col.to_string() let doc = @js.global_this()._get("document") let el = doc._call("getElementById", [@js.any(cell_id)]) let _ = el._call("focus", []) } // Keyboard handler let keydown_handler = make_table_handler( row_count, col_count, fn() { focused_row.get() }, fn() { focused_col.get() }, fn(row, col) { focused_row.set(row) focused_col.set(col) }, focus_cell, ) // Build header row let header_cells : Array[@luna.Node[@js.Any, String]] = [] for column in columns { let col_id = column.id let header_id = id + "-header-" + column.id let header_attrs : Array[(String, @luna.Attr[@js.Any, String])] = [ ("id", @luna.attr_static(header_id)), ("scope", @luna.attr_static("col")), ] if column.sortable { header_attrs.push( ( "aria-sort", @luna.attr_dynamic(fn() { if sort_column.get() == col_id { match sort_direction.get() { Ascending => "ascending" Descending => "descending" None => "none" } } else { "none" } }), ), ) header_attrs.push( ( "click", @luna.attr_handler( @luna.handler(fn(_ : @js.Any) { let curr_col = sort_column.get() let curr_dir = sort_direction.get() let new_dir = if curr_col == col_id { match curr_dir { None => Ascending Ascending => Descending Descending => None } } else { Ascending } sort_column.set(col_id) sort_direction.set(new_dir) if on_sort is Some(handler) { handler(col_id, new_dir) } }), ), ), ) header_attrs.push(("style", @luna.attr_static("cursor: pointer"))) } header_cells.push(@luna.h("th", header_attrs, [@luna.text(column.label)])) } // Build data rows let data_rows : Array[@luna.Node[@js.Any, String]] = [] for row_idx, row in rows { let row_id = row.id let tr_id = id + "-row-" + row.id let row_attrs : Array[(String, @luna.Attr[@js.Any, String])] = [ ("id", @luna.attr_static(tr_id)), ] if is_selectable { row_attrs.push( ( "aria-selected", @luna.attr_dynamic(fn() { if selected_row.get() == row_id { "true" } else { "false" } }), ), ) row_attrs.push( ( "click", @luna.attr_handler( @luna.handler(fn(_ : @js.Any) { selected_row.set(row_id) if on_row_select is Some(handler) { handler(row_id) } }), ), ), ) } // Build cells for this row let cells : Array[@luna.Node[@js.Any, String]] = [] for col_idx, cell in row.cells { let cell_id = id + "-cell-" + row_idx.to_string() + "-" + col_idx.to_string() let cell_attrs : Array[(String, @luna.Attr[@js.Any, String])] = [ ("id", @luna.attr_static(cell_id)), ( "tabindex", @luna.attr_dynamic(fn() { if focused_row.get() == row_idx && focused_col.get() == col_idx { "0" } else { "-1" } }), ), ("keydown", @luna.attr_handler(keydown_handler)), ] // Link to column header if col_idx < columns.length() { let header_id = id + "-header-" + columns[col_idx].id cell_attrs.push(("headers", @luna.attr_static(header_id))) } cells.push(@luna.h("td", cell_attrs, [@luna.text(cell)])) } data_rows.push(@luna.h("tr", row_attrs, cells)) } // Build table let table_attrs : Array[(String, @luna.Attr[@js.Any, String])] = [ ("id", @luna.attr_static(id)), ] if aria_label is Some(label) { table_attrs.push(("aria-label", @luna.attr_static(label))) } @luna.h("table", table_attrs, [ @luna.h("thead", [], [@luna.h("tr", [], header_cells)]), @luna.h("tbody", [], data_rows), ]) } ///| /// Create a simple static table with caption. /// /// Parameters: /// - id: Table ID /// - caption_text: Caption text /// - headers: Header labels /// - rows: Array of row data (each row is an array of cell strings) pub fn[E] simple_table( id : String, caption_text? : String, headers : Array[String], rows : Array[Array[String]], ) -> @luna.Node[E, String] { // Build header cells let header_cells : Array[@luna.Node[E, String]] = [] for header in headers { header_cells.push(th(scope="col", [@luna.text(header)])) } // Build data rows let data_rows : Array[@luna.Node[E, String]] = [] for row in rows { let cells : Array[@luna.Node[E, String]] = [] for cell in row { cells.push(td([@luna.text(cell)])) } data_rows.push(tr(cells)) } // Build table let table_content : Array[@luna.Node[E, String]] = [] if caption_text is Some(cap) { table_content.push(caption([@luna.text(cap)])) } table_content.push(thead([tr(header_cells)])) table_content.push(tbody(data_rows)) table(id~, table_content) }