playbook_ui 16.4.0.pre.alpha.displaybreakpoints15091 → 16.4.0.pre.alpha.play2838formcustomvalidationsconsistency15140

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4f468cd871371ba03a7e9cf2c14be1fbc8288c82d602c002d236c999249ae3e
4
- data.tar.gz: 71dcca837b8732f4bab69f44d0ecbcc057ceaf2e711c249bcd88134e3cef6f0b
3
+ metadata.gz: eca9c4e210533f089680afa7791fdf3679c6c9548918571ab7ecd7b57bfa92da
4
+ data.tar.gz: 5c4d72efd74fe5d8a81c3354d1aeb3b092733a4b9f8ad860c5479ca0f29bc381
5
5
  SHA512:
6
- metadata.gz: d8d9241711a074c871ecdb5a42e34c9135b8203a2e4b99f39b00fc0b5cee6363a49508b0c3775db90158f28d266dabc2c65d6b70a9487b01917eda4b9813a089
7
- data.tar.gz: c11eefcc856323279d5ad899ca305c3ba2eb28cd9c1ee575f4952c57e247a8de6e78eb9aec8323502010b35e51bff170551051580658364774ba3250a553f30c
6
+ metadata.gz: a7cef2caa94eef888ad35f53c2d8a4a34b2999908fdfddff010a115f8afcbc37df16cf146391b3f8053a5d0f23f2703908f33319c6838e3b1ca8a7ea70a5444d
7
+ data.tar.gz: 03f7261435bad5cc915908e9bfbe694082728e3613e5add4c2b36083fbc863668e2a9078ea303d0f2bcd545d35cc1aedc291d307edf78384b222c764d9fc0699
@@ -63,6 +63,51 @@
63
63
  width: 100%;
64
64
  }
65
65
 
66
+ // Override pb_table endcap: first and last column use same padding as rest of row
67
+ // First column
68
+ .pb_table.table-sm tbody tr td:first-child,
69
+ .pb_table.table-sm tbody tr .pb_table_td:first-child,
70
+ .pb_table.table-sm tbody .pb_table_tr td:first-child,
71
+ .pb_table.table-sm tbody .pb_table_tr .pb_table_td:first-child,
72
+ .pb_table.table-sm .pb_table_tbody tr td:first-child,
73
+ .pb_table.table-sm .pb_table_tbody tr .pb_table_td:first-child,
74
+ .pb_table.table-sm .pb_table_tbody .pb_table_tr td:first-child,
75
+ .pb_table.table-sm .pb_table_tbody .pb_table_tr .pb_table_td:first-child {
76
+ padding-left: $space-xs;
77
+ }
78
+ .pb_table.table-md tbody tr td:first-child,
79
+ .pb_table.table-md tbody tr .pb_table_td:first-child,
80
+ .pb_table.table-md tbody .pb_table_tr td:first-child,
81
+ .pb_table.table-md tbody .pb_table_tr .pb_table_td:first-child,
82
+ .pb_table.table-md .pb_table_tbody tr td:first-child,
83
+ .pb_table.table-md .pb_table_tbody tr .pb_table_td:first-child,
84
+ .pb_table.table-md .pb_table_tbody .pb_table_tr td:first-child,
85
+ .pb_table.table-md .pb_table_tbody .pb_table_tr .pb_table_td:first-child {
86
+ padding-left: $space-sm;
87
+ }
88
+
89
+ // Last column
90
+ .pb_table.table-sm tbody tr td:last-child,
91
+ .pb_table.table-sm tbody tr .pb_table_td:last-child,
92
+ .pb_table.table-sm tbody .pb_table_tr td:last-child,
93
+ .pb_table.table-sm tbody .pb_table_tr .pb_table_td:last-child,
94
+ .pb_table.table-sm .pb_table_tbody tr td:last-child,
95
+ .pb_table.table-sm .pb_table_tbody tr .pb_table_td:last-child,
96
+ .pb_table.table-sm .pb_table_tbody .pb_table_tr td:last-child,
97
+ .pb_table.table-sm .pb_table_tbody .pb_table_tr .pb_table_td:last-child {
98
+ padding-right: $space-xs;
99
+ }
100
+ .pb_table.table-md tbody tr td:last-child,
101
+ .pb_table.table-md tbody tr .pb_table_td:last-child,
102
+ .pb_table.table-md tbody .pb_table_tr td:last-child,
103
+ .pb_table.table-md tbody .pb_table_tr .pb_table_td:last-child,
104
+ .pb_table.table-md .pb_table_tbody tr td:last-child,
105
+ .pb_table.table-md .pb_table_tbody tr .pb_table_td:last-child,
106
+ .pb_table.table-md .pb_table_tbody .pb_table_tr td:last-child,
107
+ .pb_table.table-md .pb_table_tbody .pb_table_tr .pb_table_td:last-child {
108
+ padding-right: $space-sm;
109
+ }
110
+
66
111
  // Virtualized Table and Rows for Infinite Scroll
67
112
  scrollbar-gutter: stable right-edges;
68
113
  .virtualized-header-row-header {
@@ -720,7 +765,7 @@
720
765
  }
721
766
  }
722
767
 
723
- // Row Pinning - additional inline styles in RegularTableView.tsx
768
+ // Row Pinning - React uses inline style; Rails passes same style via html_options from table_body
724
769
  .pinned-row {
725
770
  box-shadow: 0 4px 10px 0 rgba($shadow, 0.16) !important;
726
771
  }
@@ -7,12 +7,12 @@
7
7
  }) %>
8
8
  <% end %>
9
9
 
10
- <%= pb_rails("table", props: { size: "sm", data_table: true, number_spacing:"tabular", responsive:"none", dark: dark, classname: object.loading ? "content-loading" : "" }.merge(object.table_props)) do %>
10
+ <%= pb_rails("table", props: { size: "sm", data_table: true, number_spacing:"tabular", responsive:"none", dark: dark, classname: object.loading ? "content-loading" : "" }.merge(object.table_props || {})) do %>
11
11
  <% if content.present? %>
12
12
  <% content.presence %>
13
13
  <% else %>
14
14
  <%= pb_rails("advanced_table/table_header", props: { table_id: object.id, column_definitions: object.column_definitions, enable_toggle_expansion: object.enable_toggle_expansion, responsive: object.responsive, loading: object.loading, selectable_rows: object.selectable_rows, show_actions_bar: object.show_actions_bar, inline_row_loading: object.inline_row_loading, persist_toggle_expansion_button: object.persist_toggle_expansion_button, table_data: object.table_data }) %>
15
- <%= pb_rails("advanced_table/table_body", props: { table_id: object.id, table_data: object.table_data, column_definitions: object.column_definitions, responsive: object.responsive, loading: object.loading, selectable_rows: object.selectable_rows, enable_toggle_expansion: object.enable_toggle_expansion, row_styling: object.row_styling, inline_row_loading: object.inline_row_loading }) %>
15
+ <%= pb_rails("advanced_table/table_body", props: { table_id: object.id, table_data: object.table_data, column_definitions: object.column_definitions, responsive: object.responsive, loading: object.loading, selectable_rows: object.selectable_rows, enable_toggle_expansion: object.enable_toggle_expansion, row_styling: object.row_styling, inline_row_loading: object.inline_row_loading, pinned_rows: object.pinned_rows }) %>
16
16
  <% end %>
17
17
  <% end %>
18
18
  <% end %>
@@ -37,6 +37,8 @@ module Playbook
37
37
  default: false
38
38
  prop :persist_toggle_expansion_button, type: Playbook::Props::Boolean,
39
39
  default: false
40
+ prop :pinned_rows, type: Playbook::Props::HashProp,
41
+ default: {}
40
42
 
41
43
  def classname
42
44
  additional_classes = [
@@ -0,0 +1,57 @@
1
+ <%# Example sort method for demonstration purposes %>
2
+ <% if params["sort"] %>
3
+ <% sort_param = params["sort"].gsub(/_(asc|desc)\z/, "") %>
4
+ <% sort_direction = params["sort"].end_with?("_asc") ? 1 : -1 %>
5
+ <% @table_data_with_id.sort! do |a, b|
6
+ value_a = a[sort_param] || a[sort_param.to_sym]
7
+ value_b = b[sort_param] || b[sort_param.to_sym]
8
+
9
+ value_a = value_a.to_i if value_a.is_a?(String) && value_a.match?(/^\d+$/)
10
+ value_b = value_b.to_i if value_b.is_a?(String) && value_b.match?(/^\d+$/)
11
+
12
+ sort_direction * (value_a <=> value_b)
13
+ end %>
14
+ <% end %>
15
+
16
+ <% column_definitions = [
17
+ {
18
+ accessor: "year",
19
+ label: "Year",
20
+ cellAccessors: ["quarter", "month", "day"],
21
+ sort_menu: [
22
+ { item: "Year", link: "?sort=year_asc#pinned_rows_table", active: params["sort"] == "year_asc", direction: "asc" },
23
+ { item: "Year", link: "?sort=year_desc#pinned_rows_table", active: params["sort"] == "year_desc", direction: "desc" }
24
+ ],
25
+ },
26
+ {
27
+ accessor: "newEnrollments",
28
+ label: "New Enrollments",
29
+ },
30
+ {
31
+ accessor: "scheduledMeetings",
32
+ label: "Scheduled Meetings",
33
+ },
34
+ {
35
+ accessor: "attendanceRate",
36
+ label: "Attendance Rate",
37
+ },
38
+ {
39
+ accessor: "completedClasses",
40
+ label: "Completed Classes",
41
+ },
42
+ {
43
+ accessor: "classCompletionRate",
44
+ label: "Class Completion Rate",
45
+ },
46
+ {
47
+ accessor: "graduatedStudents",
48
+ label: "Graduated Students",
49
+ }
50
+ ] %>
51
+
52
+ <% pinned_rows = { top: ["8"] } %>
53
+
54
+ <%= pb_rails("advanced_table", props: { id: "pinned_rows_table", table_data: @table_data_with_id, column_definitions: column_definitions, max_height: "xs", pinned_rows: pinned_rows, responsive: "none", table_props: { sticky: true }}) do %>
55
+ <%= pb_rails("advanced_table/table_header", props: { table_id: "pinned_rows_table", column_definitions: column_definitions }) %>
56
+ <%= pb_rails("advanced_table/table_body", props: { table_id: "pinned_rows_table", table_data: @table_data_with_id, column_definitions: column_definitions, pinned_rows: pinned_rows }) %>
57
+ <% end %>
@@ -0,0 +1,7 @@
1
+ Use the `pinned_rows` prop to pin specific rows to the top of an Advanced Table. Pinned rows will remain at the top when scrolling through table data and will not change position if sorting is used.
2
+
3
+ **NOTE:**
4
+ - Sticky header required: Pinned rows must be used with `sticky: true` via `table_props` (works with both responsive and non-responsive tables)
5
+ - Row ids required: Each object within the `table_data` array must contain a unique `id` in order to attach an id to all Rows for this to function.
6
+ - `pinned_rows` takes a hash with a `top` key containing an array of row ids, as shown in the code snippet below.
7
+ - For expandable rows, use the parent id in `pinned_rows[:top]`; all its children will automatically be pinned with it. If a child id is passed without the parent being pinned, nothing will be pinned.
@@ -7,6 +7,7 @@ examples:
7
7
  - advanced_table_table_props: Table Props
8
8
  - advanced_table_sticky_header_rails: Sticky Header
9
9
  - advanced_table_table_props_sticky_header: Sticky Header for Responsive Table
10
+ - advanced_table_pinned_rows_rails: Pinned Rows
10
11
  - advanced_table_beta_sort: Enable Sorting
11
12
  - advanced_table_responsive: Responsive Tables
12
13
  - advanced_table_custom_cell_rails: Custom Components for Cells
@@ -196,6 +196,17 @@ export default class PbAdvancedTable extends PbEnhancedElement {
196
196
  if (table.dataset.pbAdvancedTableInitialized) return;
197
197
  table.dataset.pbAdvancedTableInitialized = "true";
198
198
 
199
+ // Measure header height so pinned rows don't overlap when header wraps (e.g. mobile)
200
+ if (mainTable) {
201
+ PbAdvancedTable.updateStickyHeaderRowHeights(mainTable);
202
+ const resizeObserver = new ResizeObserver(() => {
203
+ PbAdvancedTable.updateStickyHeaderRowHeights(mainTable);
204
+ PbAdvancedTable.updatePinnedRowsStickyTops(mainTable);
205
+ });
206
+ resizeObserver.observe(table);
207
+ mainTable._advancedTableHeaderResizeObserver = resizeObserver;
208
+ }
209
+
199
210
  // Delegate checkbox changes
200
211
  table.addEventListener("change", (event) => {
201
212
  const checkbox = event.target.closest('input[type="checkbox"]');
@@ -315,9 +326,64 @@ export default class PbAdvancedTable extends PbEnhancedElement {
315
326
  lastVisibleRow.classList.add("last-visible-row");
316
327
  lastVisibleRow.classList.add("last-row-cell");
317
328
  }
329
+
330
+ PbAdvancedTable.updateStickyHeaderRowHeights(parentElement);
331
+ PbAdvancedTable.updatePinnedRowsStickyTops(table);
318
332
  }
319
333
  }
320
334
 
335
+ /**
336
+ * Measure thead height and set --advanced-table-header-height so pinned rows and
337
+ * multi-row sticky headers use the correct offset. Re-run when header wraps (e.g. mobile).
338
+ */
339
+ static updateStickyHeaderRowHeights(advancedTableWrapper) {
340
+ if (!advancedTableWrapper) return;
341
+ const table = advancedTableWrapper.querySelector("table.pb_table");
342
+ const thead = table?.querySelector("thead");
343
+ if (!thead) return;
344
+
345
+ const rows = Array.from(thead.querySelectorAll("tr"));
346
+ let totalHeight = 0;
347
+ rows.forEach((tr, index) => {
348
+ const h = tr.offsetHeight;
349
+ if (index === 0) {
350
+ advancedTableWrapper.style.setProperty(
351
+ "--advanced-table-header-row-0-height",
352
+ `${h}px`
353
+ );
354
+ } else if (index === 1) {
355
+ advancedTableWrapper.style.setProperty(
356
+ "--advanced-table-header-row-1-height",
357
+ `${h}px`
358
+ );
359
+ }
360
+ totalHeight += h;
361
+ });
362
+ advancedTableWrapper.style.setProperty(
363
+ "--advanced-table-header-height",
364
+ `${totalHeight}px`
365
+ );
366
+ }
367
+
368
+ /**
369
+ * Recompute sticky top for visible pinned rows so collapsed rows don't leave a gap.
370
+ * Call after expand/collapse and on load.
371
+ */
372
+ static updatePinnedRowsStickyTops(advancedTableWrapper) {
373
+ const pinnedTbody = advancedTableWrapper?.querySelector("tbody.pinned-rows-tbody");
374
+ if (!pinnedTbody) return;
375
+
376
+ const pinnedRows = Array.from(pinnedTbody.querySelectorAll("tr.pinned-row"));
377
+ const visibleRows = pinnedRows.filter(
378
+ (tr) => tr.style.display !== "none" && tr.offsetParent !== null
379
+ );
380
+
381
+ const headerOffset = "var(--advanced-table-header-height, 44px)";
382
+ visibleRows.forEach((tr, index) => {
383
+ tr.style.top = `calc(${headerOffset} + 2.5em * ${index})`;
384
+ });
385
+ }
386
+
321
387
  hideCloseIcon() {
322
388
  const closeIcon = this.element.querySelector(UP_ARROW_SELECTOR);
323
389
  closeIcon.style.display = "none";
@@ -519,3 +585,19 @@ window.expandAllRows = (element) => {
519
585
  window.expandAllSubRows = (element, rowDepth) => {
520
586
  PbAdvancedTable.handleToggleAllSubRows(element, rowDepth);
521
587
  };
588
+
589
+ // Fix header height and pinned row sticky tops on load (header wrap + collapsed rows)
590
+ function updateAllAdvancedTableStickyHeights() {
591
+ document.querySelectorAll(".pb_advanced_table").forEach((wrapper) => {
592
+ PbAdvancedTable.updateStickyHeaderRowHeights(wrapper);
593
+ PbAdvancedTable.updatePinnedRowsStickyTops(wrapper);
594
+ });
595
+ }
596
+
597
+ if (typeof document !== "undefined") {
598
+ if (document.readyState === "loading") {
599
+ document.addEventListener("DOMContentLoaded", updateAllAdvancedTableStickyHeights);
600
+ } else {
601
+ updateAllAdvancedTableStickyHeights();
602
+ }
603
+ }
@@ -1,5 +1,22 @@
1
- <%= pb_content_tag(:tbody) do %>
2
- <% object.table_data.each do |row| %>
3
- <%= render_row_and_children(row, object.column_definitions, 0, false) %>
4
- <% end %>
1
+ <% table_data = object.table_data || [] %>
2
+ <% if object.has_pinned_rows? %>
3
+ <%= pb_content_tag(:tbody, class: "pinned-rows-tbody") do %>
4
+ <% next_index = 0 %>
5
+ <% object.pinned_root_rows.each do |root_info| %>
6
+ <% row_output, next_index = object.render_row_and_children(root_info[:row], object.column_definitions, root_info[:depth], root_info[:depth] > 0, root_info[:ancestor_ids] || [], root_info[:ancestor_ids]&.first, immediate_parent_row_id: object.row_id_for(root_info[:parent_row]), is_pinned_row: true, pinned_index: next_index, initial_table_data_attributes: root_info[:depth].to_i > 0 ? object.pinned_root_initial_data_attributes(root_info) : nil) %>
7
+ <%= row_output %>
8
+ <% end %>
9
+ <% end %>
10
+ <%= pb_content_tag(:tbody) do %>
11
+ <% table_data.each do |row| %>
12
+ <% result = object.render_row_and_children(row, object.column_definitions, 0, false, skip_pinned_ids: object.pinned_ids_set) %>
13
+ <%= result.is_a?(Array) ? result[0] : result %>
14
+ <% end %>
15
+ <% end %>
16
+ <% else %>
17
+ <%= pb_content_tag(:tbody) do %>
18
+ <% table_data.each do |row| %>
19
+ <%= object.render_row_and_children(row, object.column_definitions, 0, false) %>
20
+ <% end %>
21
+ <% end %>
5
22
  <% end %>
@@ -27,6 +27,8 @@ module Playbook
27
27
  default: []
28
28
  prop :inline_row_loading, type: Playbook::Props::Boolean,
29
29
  default: false
30
+ prop :pinned_rows, type: Playbook::Props::HashProp,
31
+ default: {}
30
32
 
31
33
  def flatten_columns(columns)
32
34
  columns.flat_map do |col|
@@ -42,14 +44,21 @@ module Playbook
42
44
  end.compact
43
45
  end
44
46
 
45
- def render_row_and_children(row, column_definitions, current_depth, first_parent_child, ancestor_ids = [], top_parent_id = nil, additional_classes: "", table_data_attributes: {}, immediate_parent_row_id: nil)
47
+ def render_row_and_children(row, column_definitions, current_depth, first_parent_child, ancestor_ids = [], top_parent_id = nil, additional_classes: "", table_data_attributes: {}, immediate_parent_row_id: nil, is_pinned_row: false, pinned_index: nil, skip_pinned_ids: nil, initial_table_data_attributes: nil)
48
+ if skip_pinned_ids && row_id_for(row) && skip_pinned_ids.include?(row_id_for(row).to_s)
49
+ return is_pinned_row ? [ActiveSupport::SafeBuffer.new, pinned_index] : ActiveSupport::SafeBuffer.new
50
+ end
51
+
46
52
  top_parent_id ||= row.object_id
47
53
  new_ancestor_ids = ancestor_ids + [row.object_id]
48
- leaf_columns = flatten_columns(column_definitions)
54
+ leaf_columns = flatten_columns(column_definitions || [])
49
55
 
50
56
  output = ActiveSupport::SafeBuffer.new
51
- is_first_child_of_subrow = current_depth.positive? && first_parent_child && subrow_headers[current_depth - 1].present?
52
- last_row = subrow_headers.length == current_depth
57
+ subrow_headers_arr = subrow_headers || []
58
+ is_first_child_of_subrow = current_depth.positive? && first_parent_child && subrow_headers_arr[current_depth - 1].present?
59
+ last_row = subrow_headers_arr.length == current_depth
60
+
61
+ next_pinned_index = pinned_index
53
62
 
54
63
  subrow_ancestor_ids = ancestor_ids + ["#{row.object_id}sr"]
55
64
  subrow_data_attributes = {
@@ -58,7 +67,16 @@ module Playbook
58
67
  row_parent: "#{table_id}_#{ancestor_ids.last}",
59
68
  }
60
69
  # Subrow header if applicable
61
- output << pb_rails("advanced_table/table_subrow_header", props: { row: row, column_definitions: leaf_columns, depth: current_depth, subrow_header: subrow_headers[current_depth - 1], collapsible_trail: collapsible_trail, classname: "toggle-content", responsive: responsive, subrow_data_attributes: subrow_data_attributes, last_row: last_row, immediate_parent_row_id: immediate_parent_row_id }) if is_first_child_of_subrow && enable_toggle_expansion == "all"
70
+ if is_first_child_of_subrow && enable_toggle_expansion == "all"
71
+ subrow_props = { row: row, column_definitions: leaf_columns, depth: current_depth, subrow_header: subrow_headers_arr[current_depth - 1], collapsible_trail: collapsible_trail, classname: "toggle-content", responsive: responsive, subrow_data_attributes: subrow_data_attributes, last_row: last_row, immediate_parent_row_id: immediate_parent_row_id }
72
+ if is_pinned_row && next_pinned_index
73
+ subrow_props[:is_pinned_row] = true
74
+ subrow_props[:pinned_index] = next_pinned_index
75
+ subrow_props[:html_options] = { style: build_pinned_row_style(next_pinned_index, background: "var(--pb_table_sticky_bg, #f5f5f5)") }
76
+ next_pinned_index += 1
77
+ end
78
+ output << pb_rails("advanced_table/table_subrow_header", props: subrow_props)
79
+ end
62
80
 
63
81
  current_data_attributes = if current_depth.zero?
64
82
  {
@@ -67,11 +85,19 @@ module Playbook
67
85
  row_parent: nil,
68
86
  }
69
87
  else
70
- table_data_attributes
88
+ initial_table_data_attributes || table_data_attributes
71
89
  end
72
90
 
73
91
  # Additional class and data attributes needed for toggle logic
74
- output << pb_rails("advanced_table/table_row", props: { table_id: table_id, row: row, column_definitions: leaf_columns, depth: current_depth, collapsible_trail: collapsible_trail, classname: additional_classes, table_data_attributes: current_data_attributes, responsive: responsive, loading: loading, selectable_rows: selectable_rows, row_id: row[:id], enable_toggle_expansion: enable_toggle_expansion, row_styling: row_styling, last_row: last_row, immediate_parent_row_id: immediate_parent_row_id, inline_row_loading: inline_row_loading })
92
+ row_props = { table_id: table_id, row: row, column_definitions: leaf_columns, depth: current_depth, collapsible_trail: collapsible_trail, classname: additional_classes, table_data_attributes: current_data_attributes, responsive: responsive, loading: loading, selectable_rows: selectable_rows, row_id: row[:id], enable_toggle_expansion: enable_toggle_expansion, row_styling: row_styling, last_row: last_row, immediate_parent_row_id: immediate_parent_row_id, inline_row_loading: inline_row_loading }
93
+ if is_pinned_row && next_pinned_index
94
+ row_props[:is_pinned_row] = true
95
+ row_props[:pinned_index] = next_pinned_index
96
+ row_bg = (row_styling || []).find { |s| s[:row_id].to_s == row_id_for(row).to_s }&.[](:background_color) || "white"
97
+ row_props[:html_options] = { style: build_pinned_row_style(next_pinned_index, background: row_bg) }
98
+ next_pinned_index += 1
99
+ end
100
+ output << pb_rails("advanced_table/table_row", props: row_props)
75
101
 
76
102
  # Render inline loading row when inline_row_loading is enabled and row has empty children
77
103
  if inline_row_loading
@@ -103,11 +129,21 @@ module Playbook
103
129
  advanced_table_content: data_content,
104
130
  }
105
131
 
106
- output << render_row_and_children(child_row, column_definitions, current_depth + 1, is_first_child, new_ancestor_ids, top_parent_id, additional_classes: "toggle-content", table_data_attributes: child_data_attributes, immediate_parent_row_id: row[:id])
132
+ child_opts = { additional_classes: "toggle-content", table_data_attributes: child_data_attributes, immediate_parent_row_id: row[:id] }
133
+ child_opts[:is_pinned_row] = is_pinned_row
134
+ child_opts[:pinned_index] = next_pinned_index if is_pinned_row
135
+ child_opts[:skip_pinned_ids] = skip_pinned_ids if skip_pinned_ids
136
+
137
+ child_output, next_pinned_index = render_row_and_children(child_row, column_definitions, current_depth + 1, is_first_child, new_ancestor_ids, top_parent_id, **child_opts)
138
+ output << child_output
107
139
  end
108
140
  end
109
141
 
110
- output
142
+ if is_pinned_row
143
+ [output, next_pinned_index]
144
+ else
145
+ output
146
+ end
111
147
  end
112
148
 
113
149
  def classname
@@ -142,6 +178,76 @@ module Playbook
142
178
  end
143
179
  end
144
180
 
181
+ def row_id_for(row)
182
+ return nil if row.nil?
183
+
184
+ row[:id] || row["id"]
185
+ end
186
+
187
+ def pinned_top_ids
188
+ return [] if pinned_rows.nil? || !pinned_rows.respond_to?(:[])
189
+
190
+ top = pinned_rows["top"] || pinned_rows[:top]
191
+ Array(top).map(&:to_s)
192
+ end
193
+
194
+ def pinned_ids_set
195
+ return Set.new if pinned_top_ids.blank?
196
+
197
+ set = Set.new
198
+ pinned_root_rows.each do |root|
199
+ collect_row_and_descendant_ids(root[:row], set)
200
+ end
201
+ set
202
+ end
203
+
204
+ def collect_row_and_descendant_ids(row, set)
205
+ id = row_id_for(row)
206
+ set.add(id.to_s) if id
207
+ row_children_for(row)&.each { |child| collect_row_and_descendant_ids(child, set) }
208
+ end
209
+
210
+ def find_row_by_id(data, id, depth: 0, ancestor_ids: [], parent_row: nil)
211
+ id_str = id.to_s
212
+ Array(data).each do |row|
213
+ return { row: row, depth: depth, ancestor_ids: ancestor_ids, parent_row: parent_row } if row_id_for(row).to_s == id_str
214
+
215
+ found = find_row_by_id(row_children_for(row), id_str, depth: depth + 1, ancestor_ids: ancestor_ids + [row.object_id], parent_row: row)
216
+ return found if found
217
+ end
218
+ nil
219
+ end
220
+
221
+ def pinned_root_rows
222
+ return [] if pinned_top_ids.blank?
223
+
224
+ pinned_top_ids.filter_map { |id| find_row_by_id(table_data, id) }
225
+ end
226
+
227
+ def has_pinned_rows?
228
+ pinned_root_rows.any?
229
+ end
230
+
231
+ # Build inline style for sticky pinned row (matches React). Pass via html_options so the tr gets the attribute.
232
+ def build_pinned_row_style(pinned_index, background: "white")
233
+ header_offset = "var(--advanced-table-header-height, 44px)"
234
+ row_offset = "calc(2.5em * #{pinned_index})"
235
+ "position: sticky; top: calc(#{header_offset} + #{row_offset}); z-index: 3; background: #{background};"
236
+ end
237
+
238
+ def pinned_root_initial_data_attributes(root_info)
239
+ return {} if root_info[:depth].to_i.zero?
240
+
241
+ anc = root_info[:ancestor_ids] || []
242
+ content = (anc + [root_info[:row].object_id]).join("-")
243
+ {
244
+ top_parent: "#{table_id}_#{anc.first}",
245
+ row_depth: root_info[:depth],
246
+ row_parent: "#{table_id}_#{anc.last}",
247
+ advanced_table_content: content,
248
+ }
249
+ end
250
+
145
251
  def cell_accessors_length(col_defs)
146
252
  first_col = col_defs.first
147
253
  return 0 unless first_col
@@ -3,9 +3,11 @@
3
3
  button_color = row_style&.[](:expand_button_color)
4
4
  bg_color = row_style&.[](:background_color)
5
5
  font_color = row_style&.[](:font_color)
6
+ tr_options = (object.html_options || {}).stringify_keys
7
+ tr_options["class"] = [tr_options["class"], object.classname].reject(&:blank?).join(" ")
6
8
  %>
7
9
 
8
- <%= pb_content_tag(:tr) do %>
10
+ <%= pb_content_tag(:tr, tr_options) do %>
9
11
  <% has_separate_checkbox = object.selectable_rows && object.enable_toggle_expansion == "none" %>
10
12
  <% if has_separate_checkbox %>
11
13
  <%= object.render_checkbox_cell %>
@@ -35,13 +35,24 @@ module Playbook
35
35
  default: ""
36
36
  prop :inline_row_loading, type: Playbook::Props::Boolean,
37
37
  default: false
38
+ prop :is_pinned_row, type: Playbook::Props::Boolean,
39
+ default: false
40
+ prop :pinned_index, type: Playbook::Props::Numeric,
41
+ default: nil
42
+ prop :html_options, type: Playbook::Props::HashProp,
43
+ default: {}
44
+ prop :classname, type: Playbook::Props::String,
45
+ default: ""
38
46
 
39
47
  def data
40
48
  Hash(prop(:data)).merge(table_data_attributes)
41
49
  end
42
50
 
43
51
  def classname
44
- generate_classname("pb_table_tr", "pb-bg-row-white", subrow_depth_classname, separator: " ")
52
+ classes = ["pb_table_tr", "pb-bg-row-white", subrow_depth_classname]
53
+ classes << "pinned-row" if is_pinned_row
54
+ classes.reject!(&:blank?)
55
+ generate_classname(*classes, separator: " ")
45
56
  end
46
57
 
47
58
  def td_classname(column, index)
@@ -1,4 +1,7 @@
1
- <%= pb_content_tag(:tr) do %>
1
+ <% tr_options = (object.html_options || {}).stringify_keys %>
2
+ <% tr_options["class"] = [tr_options["class"], object.classname].reject(&:blank?).join(" ") %>
3
+
4
+ <%= pb_content_tag(:tr, tr_options) do %>
2
5
  <% object.column_definitions.each_with_index do |column, index| %>
3
6
  <%= pb_rails("table/table_cell", props: { classname: object.td_classname(index) }) do %>
4
7
  <%= pb_rails("flex", props:{ align: "center", justify: "start" }) do %>
@@ -19,13 +19,21 @@ module Playbook
19
19
  prop :responsive, type: Playbook::Props::Enum,
20
20
  values: %w[none scroll],
21
21
  default: "scroll"
22
+ prop :is_pinned_row, type: Playbook::Props::Boolean,
23
+ default: false
24
+ prop :pinned_index, type: Playbook::Props::Numeric,
25
+ default: nil
26
+ prop :html_options, type: Playbook::Props::HashProp,
27
+ default: {}
22
28
 
23
29
  def data
24
30
  Hash(prop(:data)).merge(subrow_data_attributes)
25
31
  end
26
32
 
27
33
  def classname
28
- generate_classname("pb_table_tr", "bg-silver", "pb_subrow_header", subrow_depth_classname, separator: " ")
34
+ classes = ["pb_table_tr", "bg-silver", "pb_subrow_header", subrow_depth_classname]
35
+ classes << "pinned-row" if is_pinned_row
36
+ generate_classname(*classes, separator: " ")
29
37
  end
30
38
 
31
39
  def td_classname(index)