From 30d177a5e18ad4d5ab159bb9a8223b33037210b8 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 02:28:52 +0000 Subject: [PATCH 01/19] feat: Add dark mode support to TableWidget - Updates table_widget.css to use CSS variables for colors where possible. - Adds @media (prefers-color-scheme: dark) block to handle dark mode overrides. - Updates text and background colors to be legible in dark mode. - Adds unit test to verify CSS contains dark mode media query. --- bigframes/display/table_widget.css | 174 ++++++++++++++++----------- tests/unit/display/test_anywidget.py | 18 +++ 2 files changed, 122 insertions(+), 70 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 34134b043d..31ada74b26 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -15,138 +15,172 @@ */ .bigframes-widget { - display: flex; - flex-direction: column; + display: flex; + flex-direction: column; } .bigframes-widget .table-container { - max-height: 620px; - overflow: auto; + max-height: 620px; + overflow: auto; } .bigframes-widget .footer { - align-items: center; - /* TODO(b/460861328): We will support dark mode in a media selector once we - * determine how to override the background colors as well. */ - color: black; - display: flex; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - font-size: 0.8rem; - justify-content: space-between; - padding: 8px; + align-items: center; + color: var(--colab-primary-text-color, var(--jp-ui-font-color0, black)); + display: flex; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + font-size: 0.8rem; + justify-content: space-between; + padding: 8px; } .bigframes-widget .footer > * { - flex: 1; + flex: 1; } .bigframes-widget .pagination { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - justify-content: center; - padding: 4px; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + padding: 4px; } .bigframes-widget .page-indicator { - margin: 0 8px; + margin: 0 8px; } .bigframes-widget .row-count { - margin: 0 8px; + margin: 0 8px; } .bigframes-widget .page-size { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - justify-content: end; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: end; } .bigframes-widget .page-size label { - margin-right: 8px; + margin-right: 8px; } .bigframes-widget table { - border-collapse: collapse; - /* TODO(b/460861328): We will support dark mode in a media selector once we - * determine how to override the background colors as well. */ - color: black; - text-align: left; + border-collapse: collapse; + color: var(--colab-primary-text-color, var(--jp-ui-font-color0, black)); + text-align: left; } .bigframes-widget th { - background-color: var(--colab-primary-surface-color, var(--jp-layout-color0)); - padding: 0; - position: sticky; - text-align: left; - top: 0; - z-index: 1; + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, white) + ); + padding: 0; + position: sticky; + text-align: left; + top: 0; + z-index: 1; } .bigframes-widget .bf-header-content { - box-sizing: border-box; - height: 100%; - overflow: auto; - padding: 0.5em; - resize: horizontal; - width: 100%; + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 0.5em; + resize: horizontal; + width: 100%; } .bigframes-widget th .sort-indicator { - padding-left: 4px; - visibility: hidden; + padding-left: 4px; + visibility: hidden; } .bigframes-widget th:hover .sort-indicator { - visibility: visible; + visibility: visible; } .bigframes-widget button { - cursor: pointer; - display: inline-block; - text-align: center; - text-decoration: none; - user-select: none; - vertical-align: middle; + cursor: pointer; + display: inline-block; + text-align: center; + text-decoration: none; + user-select: none; + vertical-align: middle; } .bigframes-widget button:disabled { - opacity: 0.65; - pointer-events: none; + opacity: 0.65; + pointer-events: none; } .bigframes-widget .bigframes-error-message { - background-color: #fbe; - border: 1px solid red; - border-radius: 4px; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - font-size: 14px; - margin-bottom: 8px; - padding: 8px; + background-color: #fbe; + border: 1px solid red; + border-radius: 4px; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + font-size: 14px; + margin-bottom: 8px; + padding: 8px; } .bigframes-widget .cell-align-right { - text-align: right; + text-align: right; } .bigframes-widget .cell-align-left { - text-align: left; + text-align: left; } .bigframes-widget .null-value { - color: gray; + color: gray; } .bigframes-widget td { - padding: 0.5em; + padding: 0.5em; } .bigframes-widget tr:hover td, .bigframes-widget td.row-hover { - background-color: var(--colab-hover-surface-color, var(--jp-layout-color2)); + background-color: var( + --colab-hover-surface-color, + var(--jp-layout-color2, #f5f5f5) + ); +} + +@media (prefers-color-scheme: dark) { + .bigframes-widget .footer, + .bigframes-widget table { + color: var(--colab-primary-text-color, var(--jp-ui-font-color0, white)); + } + + .bigframes-widget th { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, #383838) + ); + } + + .bigframes-widget .bigframes-error-message { + background-color: #511; + border: 1px solid #f88; + color: #fcc; + } + + .bigframes-widget .null-value { + color: #aaa; + } + + .bigframes-widget tr:hover td, + .bigframes-widget td.row-hover { + background-color: var( + --colab-hover-surface-color, + var(--jp-layout-color2, #444) + ); + } } diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py index 2ca8c0da2f..482e81d502 100644 --- a/tests/unit/display/test_anywidget.py +++ b/tests/unit/display/test_anywidget.py @@ -78,3 +78,21 @@ def handler(signum, frame): finally: if has_sigalrm: signal.alarm(0) + + +def test_css_contains_dark_mode_media_query(): + from bigframes.display.anywidget import TableWidget + + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) + # mock_df.columns and mock_df.dtypes are needed for __init__ + mock_df.columns = ["col1"] + mock_df.dtypes = {"col1": "object"} + + # Mock _block to avoid AttributeError during _set_table_html + mock_block = mock.Mock() + mock_block.has_index = False + mock_df._block = mock_block + + with mock.patch.object(TableWidget, "_initial_load"): + widget = TableWidget(mock_df) + assert "@media (prefers-color-scheme: dark)" in widget._css From 6bc5c482a5e11d9b1297bffc6d86da0dc88580df Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 02:37:43 +0000 Subject: [PATCH 02/19] fix: Add VS Code specific CSS variables for proper dark mode support --- bigframes/display/table_widget.css | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 31ada74b26..a7dbc7d3a1 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -26,7 +26,10 @@ .bigframes-widget .footer { align-items: center; - color: var(--colab-primary-text-color, var(--jp-ui-font-color0, black)); + color: var( + --colab-primary-text-color, + var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) + ); display: flex; font-family: "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; @@ -70,14 +73,17 @@ .bigframes-widget table { border-collapse: collapse; - color: var(--colab-primary-text-color, var(--jp-ui-font-color0, black)); + color: var( + --colab-primary-text-color, + var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) + ); text-align: left; } .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, white) + var(--jp-layout-color0, var(--vscode-editor-background, white)) ); padding: 0; position: sticky; @@ -149,20 +155,23 @@ .bigframes-widget td.row-hover { background-color: var( --colab-hover-surface-color, - var(--jp-layout-color2, #f5f5f5) + var(--jp-layout-color2, var(--vscode-list-hoverBackground, #f5f5f5)) ); } @media (prefers-color-scheme: dark) { .bigframes-widget .footer, .bigframes-widget table { - color: var(--colab-primary-text-color, var(--jp-ui-font-color0, white)); + color: var( + --colab-primary-text-color, + var(--jp-ui-font-color0, var(--vscode-editor-foreground, white)) + ); } .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, #383838) + var(--jp-layout-color0, var(--vscode-editor-background, #383838)) ); } @@ -180,7 +189,7 @@ .bigframes-widget td.row-hover { background-color: var( --colab-hover-surface-color, - var(--jp-layout-color2, #444) + var(--jp-layout-color2, var(--vscode-list-hoverBackground, #444)) ); } } From b97433585fd094da0c1007b6868422c09ceb6f20 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 02:51:35 +0000 Subject: [PATCH 03/19] fix: Use adaptive CSS system colors to fix strict theme fallbacks --- bigframes/display/table_widget.css | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index a7dbc7d3a1..7f6fdd448f 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -28,7 +28,7 @@ align-items: center; color: var( --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) + var(--jp-ui-font-color0, var(--vscode-editor-foreground, inherit)) ); display: flex; font-family: @@ -75,7 +75,7 @@ border-collapse: collapse; color: var( --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) + var(--jp-ui-font-color0, var(--vscode-editor-foreground, inherit)) ); text-align: left; } @@ -83,7 +83,7 @@ .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, white)) + var(--jp-layout-color0, var(--vscode-editor-background, Canvas)) ); padding: 0; position: sticky; @@ -155,7 +155,10 @@ .bigframes-widget td.row-hover { background-color: var( --colab-hover-surface-color, - var(--jp-layout-color2, var(--vscode-list-hoverBackground, #f5f5f5)) + var( + --jp-layout-color2, + var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.1)) + ) ); } @@ -164,14 +167,14 @@ .bigframes-widget table { color: var( --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, white)) + var(--jp-ui-font-color0, var(--vscode-editor-foreground, inherit)) ); } .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #383838)) + var(--jp-layout-color0, var(--vscode-editor-background, Canvas)) ); } @@ -189,7 +192,10 @@ .bigframes-widget td.row-hover { background-color: var( --colab-hover-surface-color, - var(--jp-layout-color2, var(--vscode-list-hoverBackground, #444)) + var( + --jp-layout-color2, + var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.1)) + ) ); } } From 0a350cc19e2fb1ac0973385fb7078c46b69cd3f0 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 02:56:16 +0000 Subject: [PATCH 04/19] fix: Restore strict fallbacks and fix VS Code dark mode background --- bigframes/display/table_widget.css | 44 +++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 13 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 7f6fdd448f..ae5dc0703c 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -28,7 +28,7 @@ align-items: center; color: var( --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, inherit)) + var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) ); display: flex; font-family: @@ -75,7 +75,11 @@ border-collapse: collapse; color: var( --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, inherit)) + var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) + ); + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, white)) ); text-align: left; } @@ -83,7 +87,7 @@ .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, Canvas)) + var(--jp-layout-color0, var(--vscode-editor-background, white)) ); padding: 0; position: sticky; @@ -92,6 +96,14 @@ z-index: 1; } +/* Ensure rows have a background to override external striping if needed */ +.bigframes-widget table tbody tr { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, white)) + ); +} + .bigframes-widget .bf-header-content { box-sizing: border-box; height: 100%; @@ -155,10 +167,7 @@ .bigframes-widget td.row-hover { background-color: var( --colab-hover-surface-color, - var( - --jp-layout-color2, - var(--vscode-list-hoverBackground, rgba(128, 128, 128, 0.1)) - ) + var(--jp-layout-color2, var(--vscode-list-hoverBackground, #f5f5f5)) ); } @@ -167,14 +176,26 @@ .bigframes-widget table { color: var( --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, inherit)) + var(--jp-ui-font-color0, var(--vscode-editor-foreground, white)) + ); + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, #202124)) ); } .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, Canvas)) + var(--jp-layout-color0, var(--vscode-editor-background, #383838)) + ); + } + + /* Force dark background on rows to override white stripes from external CSS */ + .bigframes-widget table tbody tr { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, #202124)) ); } @@ -192,10 +213,7 @@ .bigframes-widget td.row-hover { background-color: var( --colab-hover-surface-color, - var( - --jp-layout-color2, - var(--vscode-list-hoverBackground, rgba(255, 255, 255, 0.1)) - ) + var(--jp-layout-color2, var(--vscode-list-hoverBackground, #444)) ); } } From 89b84b3278072e3e5d5c10a24610b660825df842 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 03:18:24 +0000 Subject: [PATCH 05/19] style: Implement zebra striping and remove hover effects in TableWidget --- bigframes/display/table_widget.css | 50 +++++++++++++++--------------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index ae5dc0703c..73749e73bf 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -84,10 +84,11 @@ text-align: left; } +/* Light Mode Header: Light Gray */ .bigframes-widget th { background-color: var( --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, white)) + var(--jp-layout-color0, var(--vscode-editor-background, #f5f5f5)) ); padding: 0; position: sticky; @@ -96,14 +97,25 @@ z-index: 1; } -/* Ensure rows have a background to override external striping if needed */ -.bigframes-widget table tbody tr { +.bigframes-widget td { + padding: 0.5em; +} + +/* Light Mode Stripes: Odd = White, Even = Light Gray */ +.bigframes-widget table tbody tr:nth-child(odd) td { background-color: var( --colab-primary-surface-color, var(--jp-layout-color0, var(--vscode-editor-background, white)) ); } +.bigframes-widget table tbody tr:nth-child(even) td { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, #f5f5f5)) + ); +} + .bigframes-widget .bf-header-content { box-sizing: border-box; height: 100%; @@ -159,18 +171,6 @@ color: gray; } -.bigframes-widget td { - padding: 0.5em; -} - -.bigframes-widget tr:hover td, -.bigframes-widget td.row-hover { - background-color: var( - --colab-hover-surface-color, - var(--jp-layout-color2, var(--vscode-list-hoverBackground, #f5f5f5)) - ); -} - @media (prefers-color-scheme: dark) { .bigframes-widget .footer, .bigframes-widget table { @@ -184,15 +184,23 @@ ); } + /* Dark Mode Header: Black or Theme Background */ .bigframes-widget th { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, black)) + ); + } + + /* Dark Mode Stripes: Odd = Charcoal (#383838), Even = Dark Gray (#202124) */ + .bigframes-widget table tbody tr:nth-child(odd) td { background-color: var( --colab-primary-surface-color, var(--jp-layout-color0, var(--vscode-editor-background, #383838)) ); } - /* Force dark background on rows to override white stripes from external CSS */ - .bigframes-widget table tbody tr { + .bigframes-widget table tbody tr:nth-child(even) td { background-color: var( --colab-primary-surface-color, var(--jp-layout-color0, var(--vscode-editor-background, #202124)) @@ -208,12 +216,4 @@ .bigframes-widget .null-value { color: #aaa; } - - .bigframes-widget tr:hover td, - .bigframes-widget td.row-hover { - background-color: var( - --colab-hover-surface-color, - var(--jp-layout-color2, var(--vscode-list-hoverBackground, #444)) - ); - } } From dc9073b5608ba37b446075669b0a78ccf50fc512 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 03:24:56 +0000 Subject: [PATCH 06/19] feat: Add debug info to TableWidget footer to diagnose theme issues --- bigframes/display/table_widget.js | 68 +++++++++++++++---------------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index ae49eaf9cf..b2b0d0dcdb 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -48,6 +48,15 @@ function render({ model, el }) { const footer = document.createElement("footer"); footer.classList.add("footer"); + // Debug info container + const debugInfo = document.createElement("div"); + debugInfo.classList.add("debug-info"); + debugInfo.style.fontSize = "10px"; + debugInfo.style.color = "gray"; + debugInfo.style.marginTop = "8px"; + debugInfo.style.borderTop = "1px solid #ccc"; + debugInfo.style.paddingTop = "4px"; + // Pagination controls const paginationContainer = document.createElement("div"); paginationContainer.classList.add("pagination"); @@ -133,6 +142,28 @@ function render({ model, el }) { model.save_changes(); } + function updateDebugInfo() { + const computedStyle = getComputedStyle(el); + const prefersDark = window.matchMedia( + "(prefers-color-scheme: dark)", + ).matches; + const vscodeFg = computedStyle.getPropertyValue( + "--vscode-editor-foreground", + ); + const vscodeBg = computedStyle.getPropertyValue( + "--vscode-editor-background", + ); + + debugInfo.innerHTML = ` + Debug Info:
+ Prefers Dark Scheme: ${prefersDark}
+ VSCode Foreground: ${vscodeFg || "Not Set"}
+ VSCode Background: ${vscodeBg || "Not Set"}
+ Computed Color: ${computedStyle.color}
+ Computed Bg: ${computedStyle.backgroundColor} + `; + } + /** Updates the HTML in the table container and refreshes button states. */ function handleTableHTMLChange() { // Note: Using innerHTML is safe here because the content is generated @@ -209,42 +240,8 @@ function render({ model, el }) { } }); - const table = tableContainer.querySelector("table"); - if (table) { - const tableBody = table.querySelector("tbody"); - - /** - * Handles row hover events. - * @param {!Event} event - The mouse event. - * @param {boolean} isHovering - True to add hover class, false to remove. - */ - function handleRowHover(event, isHovering) { - const cell = event.target.closest("td"); - if (cell) { - const row = cell.closest("tr"); - const origRowId = row.dataset.origRow; - if (origRowId) { - const allCellsInGroup = tableBody.querySelectorAll( - `tr[data-orig-row="${origRowId}"] td`, - ); - allCellsInGroup.forEach((c) => { - c.classList.toggle("row-hover", isHovering); - }); - } - } - } - - if (tableBody) { - tableBody.addEventListener("mouseover", (event) => - handleRowHover(event, true), - ); - tableBody.addEventListener("mouseout", (event) => - handleRowHover(event, false), - ); - } - } - updateButtonStates(); + updateDebugInfo(); } // Add error message handler @@ -288,6 +285,7 @@ function render({ model, el }) { footer.appendChild(rowCountLabel); footer.appendChild(paginationContainer); footer.appendChild(pageSizeContainer); + footer.appendChild(debugInfo); el.appendChild(errorContainer); el.appendChild(tableContainer); From bbc42344d920e10983f637b165eaf3588d71b9f1 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 03:32:54 +0000 Subject: [PATCH 07/19] feat: Detect dark mode via body classes/attributes in TableWidget --- bigframes/display/table_widget.js | 60 ++++++++++++++----------------- 1 file changed, 27 insertions(+), 33 deletions(-) diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index b2b0d0dcdb..69493a24c2 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -39,6 +39,33 @@ function render({ model, el }) { // Main container with a unique class for CSS scoping el.classList.add("bigframes-widget"); + // Theme detection logic + function updateTheme() { + const body = document.body; + // Check for common dark mode indicators in VS Code / Jupyter + const isDark = + body.classList.contains("vscode-dark") || + body.classList.contains("theme-dark") || + body.dataset.theme === "dark" || + body.getAttribute("data-vscode-theme-kind") === "vscode-dark"; + + if (isDark) { + el.classList.add("bigframes-dark-mode"); + } else { + el.classList.remove("bigframes-dark-mode"); + } + } + + // Initial check + updateTheme(); + + // Observe body attribute changes to react to theme switching + const observer = new MutationObserver(updateTheme); + observer.observe(document.body, { + attributes: true, + attributeFilter: ["class", "data-theme", "data-vscode-theme-kind"], + }); + // Add error message container at the top const errorContainer = document.createElement("div"); errorContainer.classList.add("error-message"); @@ -48,15 +75,6 @@ function render({ model, el }) { const footer = document.createElement("footer"); footer.classList.add("footer"); - // Debug info container - const debugInfo = document.createElement("div"); - debugInfo.classList.add("debug-info"); - debugInfo.style.fontSize = "10px"; - debugInfo.style.color = "gray"; - debugInfo.style.marginTop = "8px"; - debugInfo.style.borderTop = "1px solid #ccc"; - debugInfo.style.paddingTop = "4px"; - // Pagination controls const paginationContainer = document.createElement("div"); paginationContainer.classList.add("pagination"); @@ -142,28 +160,6 @@ function render({ model, el }) { model.save_changes(); } - function updateDebugInfo() { - const computedStyle = getComputedStyle(el); - const prefersDark = window.matchMedia( - "(prefers-color-scheme: dark)", - ).matches; - const vscodeFg = computedStyle.getPropertyValue( - "--vscode-editor-foreground", - ); - const vscodeBg = computedStyle.getPropertyValue( - "--vscode-editor-background", - ); - - debugInfo.innerHTML = ` - Debug Info:
- Prefers Dark Scheme: ${prefersDark}
- VSCode Foreground: ${vscodeFg || "Not Set"}
- VSCode Background: ${vscodeBg || "Not Set"}
- Computed Color: ${computedStyle.color}
- Computed Bg: ${computedStyle.backgroundColor} - `; - } - /** Updates the HTML in the table container and refreshes button states. */ function handleTableHTMLChange() { // Note: Using innerHTML is safe here because the content is generated @@ -241,7 +237,6 @@ function render({ model, el }) { }); updateButtonStates(); - updateDebugInfo(); } // Add error message handler @@ -285,7 +280,6 @@ function render({ model, el }) { footer.appendChild(rowCountLabel); footer.appendChild(paginationContainer); footer.appendChild(pageSizeContainer); - footer.appendChild(debugInfo); el.appendChild(errorContainer); el.appendChild(tableContainer); From 01c3534f08da580d98c886c56540ea05e34e15ed Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 03:33:24 +0000 Subject: [PATCH 08/19] style: Support explicit dark mode class .bigframes-dark-mode in CSS --- bigframes/display/table_widget.css | 49 ++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 73749e73bf..57502b180b 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -171,6 +171,11 @@ color: gray; } +/* + Dark Mode Styles + Applied via @media query OR .bigframes-dark-mode class (detected via JS) +*/ + @media (prefers-color-scheme: dark) { .bigframes-widget .footer, .bigframes-widget table { @@ -217,3 +222,47 @@ color: #aaa; } } + +/* Duplicated Dark Mode rules for explicit class detection */ +.bigframes-widget.bigframes-dark-mode .footer, +.bigframes-widget.bigframes-dark-mode table { + color: var( + --colab-primary-text-color, + var(--jp-ui-font-color0, var(--vscode-editor-foreground, white)) + ); + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, #202124)) + ); +} + +.bigframes-widget.bigframes-dark-mode th { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, black)) + ); +} + +.bigframes-widget.bigframes-dark-mode table tbody tr:nth-child(odd) td { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, #383838)) + ); +} + +.bigframes-widget.bigframes-dark-mode table tbody tr:nth-child(even) td { + background-color: var( + --colab-primary-surface-color, + var(--jp-layout-color0, var(--vscode-editor-background, #202124)) + ); +} + +.bigframes-widget.bigframes-dark-mode .bigframes-error-message { + background-color: #511; + border: 1px solid #f88; + color: #fcc; +} + +.bigframes-widget.bigframes-dark-mode .null-value { + color: #aaa; +} From 31fbdf579b11856b5d249301c8db506576b85e5a Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 04:18:01 +0000 Subject: [PATCH 09/19] style: fix the color theme for dark mode --- bigframes/display/html.py | 2 +- bigframes/display/table_widget.css | 224 ++++++++++++----------------- bigframes/display/table_widget.js | 18 +-- 3 files changed, 105 insertions(+), 139 deletions(-) diff --git a/bigframes/display/html.py b/bigframes/display/html.py index 912f1d7e3a..0234829067 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -49,7 +49,7 @@ def render_html( ) -> str: """Render a pandas DataFrame to HTML with specific styling.""" orderable_columns = orderable_columns or [] - classes = "dataframe table table-striped table-hover" + classes = "dataframe table" table_html_parts = [f''] table_html_parts.append(_render_table_header(dataframe, orderable_columns)) table_html_parts.append(_render_table_body(dataframe)) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 57502b180b..d4c11f6096 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -15,27 +15,80 @@ */ .bigframes-widget { + /* Default Light Mode Variables */ + --bf-bg: white; + --bf-fg: black; + --bf-header-bg: #f5f5f5; + --bf-row-odd-bg: white; + --bf-row-even-bg: #f5f5f5; + --bf-border-color: #ccc; + --bf-null-fg: gray; + --bf-error-bg: #fbe; + --bf-error-border: red; + --bf-error-fg: black; + display: flex; flex-direction: column; + /* Double class to increase specificity + !important to override framework styles */ + background-color: var(--bf-bg) !important; + color: var(--bf-fg) !important; + font-family: + "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; + padding: 0 !important; + margin: 0 !important; + box-sizing: border-box; +} + +.bigframes-widget * { + box-sizing: border-box; +} + +/* Dark Mode Overrides via Media Query */ +@media (prefers-color-scheme: dark) { + .bigframes-widget { + --bf-bg: var(--vscode-editor-background, #202124); + --bf-fg: white; + --bf-header-bg: var(--vscode-editor-background, black); + --bf-row-odd-bg: #383838; + --bf-row-even-bg: #202124; + --bf-border-color: #444; + --bf-null-fg: #aaa; + --bf-error-bg: #511; + --bf-error-border: #f88; + --bf-error-fg: #fcc; + } +} + +/* Dark Mode Overrides via Explicit Class */ +.bigframes-widget.bigframes-dark-mode.bigframes-dark-mode { + --bf-bg: var(--vscode-editor-background, #202124); + --bf-fg: white; + --bf-header-bg: var(--vscode-editor-background, black); + --bf-row-odd-bg: #383838; + --bf-row-even-bg: #202124; + --bf-border-color: #444; + --bf-null-fg: #aaa; + --bf-error-bg: #511; + --bf-error-border: #f88; + --bf-error-fg: #fcc; } .bigframes-widget .table-container { max-height: 620px; overflow: auto; + background-color: var(--bf-bg); + padding: 0; + margin: 0; } .bigframes-widget .footer { align-items: center; - color: var( - --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) - ); display: flex; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; font-size: 0.8rem; justify-content: space-between; padding: 8px; + background-color: var(--bf-bg); + color: var(--bf-fg); } .bigframes-widget .footer > * { @@ -71,49 +124,50 @@ margin-right: 8px; } -.bigframes-widget table { - border-collapse: collapse; - color: var( - --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, black)) - ); - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, white)) - ); +.bigframes-widget table, +.bigframes-widget table.dataframe { + border-collapse: collapse !important; + border-spacing: 0 !important; text-align: left; + width: auto !important; /* Fix stretching */ + background-color: var(--bf-bg) !important; + color: var(--bf-fg) !important; + /* Explicitly override border="1" defaults */ + border: 1px solid var(--bf-border-color) !important; + box-shadow: none !important; + outline: none !important; + margin: 0 !important; +} + +.bigframes-widget tr { + border: none !important; } -/* Light Mode Header: Light Gray */ .bigframes-widget th { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #f5f5f5)) - ); + background-color: var(--bf-header-bg) !important; + color: var(--bf-fg) !important; padding: 0; position: sticky; text-align: left; top: 0; z-index: 1; + border: 1px solid var(--bf-border-color) !important; } .bigframes-widget td { padding: 0.5em; + border: 1px solid var(--bf-border-color) !important; + color: var(--bf-fg) !important; } -/* Light Mode Stripes: Odd = White, Even = Light Gray */ +.bigframes-widget table tbody tr:nth-child(odd), .bigframes-widget table tbody tr:nth-child(odd) td { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, white)) - ); + background-color: var(--bf-row-odd-bg) !important; } +.bigframes-widget table tbody tr:nth-child(even), .bigframes-widget table tbody tr:nth-child(even) td { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #f5f5f5)) - ); + background-color: var(--bf-row-even-bg) !important; } .bigframes-widget .bf-header-content { @@ -141,6 +195,11 @@ text-decoration: none; user-select: none; vertical-align: middle; + color: inherit; + background-color: transparent; + border: 1px solid currentColor; + border-radius: 4px; + padding: 2px 8px; } .bigframes-widget button:disabled { @@ -149,11 +208,10 @@ } .bigframes-widget .bigframes-error-message { - background-color: #fbe; - border: 1px solid red; + background-color: var(--bf-error-bg); + border: 1px solid var(--bf-error-border); + color: var(--bf-error-fg); border-radius: 4px; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; font-size: 14px; margin-bottom: 8px; padding: 8px; @@ -168,101 +226,9 @@ } .bigframes-widget .null-value { - color: gray; -} - -/* - Dark Mode Styles - Applied via @media query OR .bigframes-dark-mode class (detected via JS) -*/ - -@media (prefers-color-scheme: dark) { - .bigframes-widget .footer, - .bigframes-widget table { - color: var( - --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, white)) - ); - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #202124)) - ); - } - - /* Dark Mode Header: Black or Theme Background */ - .bigframes-widget th { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, black)) - ); - } - - /* Dark Mode Stripes: Odd = Charcoal (#383838), Even = Dark Gray (#202124) */ - .bigframes-widget table tbody tr:nth-child(odd) td { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #383838)) - ); - } - - .bigframes-widget table tbody tr:nth-child(even) td { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #202124)) - ); - } - - .bigframes-widget .bigframes-error-message { - background-color: #511; - border: 1px solid #f88; - color: #fcc; - } - - .bigframes-widget .null-value { - color: #aaa; - } -} - -/* Duplicated Dark Mode rules for explicit class detection */ -.bigframes-widget.bigframes-dark-mode .footer, -.bigframes-widget.bigframes-dark-mode table { - color: var( - --colab-primary-text-color, - var(--jp-ui-font-color0, var(--vscode-editor-foreground, white)) - ); - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #202124)) - ); -} - -.bigframes-widget.bigframes-dark-mode th { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, black)) - ); -} - -.bigframes-widget.bigframes-dark-mode table tbody tr:nth-child(odd) td { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #383838)) - ); -} - -.bigframes-widget.bigframes-dark-mode table tbody tr:nth-child(even) td { - background-color: var( - --colab-primary-surface-color, - var(--jp-layout-color0, var(--vscode-editor-background, #202124)) - ); -} - -.bigframes-widget.bigframes-dark-mode .bigframes-error-message { - background-color: #511; - border: 1px solid #f88; - color: #fcc; + color: var(--bf-null-fg); } -.bigframes-widget.bigframes-dark-mode .null-value { - color: #aaa; +.bigframes-widget .debug-info { + border-top: 1px solid var(--bf-border-color) !important; } diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 69493a24c2..de3da0ec01 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -39,6 +39,15 @@ function render({ model, el }) { // Main container with a unique class for CSS scoping el.classList.add("bigframes-widget"); + // Add error message container at the top + const errorContainer = document.createElement("div"); + errorContainer.classList.add("error-message"); + + const tableContainer = document.createElement("div"); + tableContainer.classList.add("table-container"); + const footer = document.createElement("footer"); + footer.classList.add("footer"); + // Theme detection logic function updateTheme() { const body = document.body; @@ -66,15 +75,6 @@ function render({ model, el }) { attributeFilter: ["class", "data-theme", "data-vscode-theme-kind"], }); - // Add error message container at the top - const errorContainer = document.createElement("div"); - errorContainer.classList.add("error-message"); - - const tableContainer = document.createElement("div"); - tableContainer.classList.add("table-container"); - const footer = document.createElement("footer"); - footer.classList.add("footer"); - // Pagination controls const paginationContainer = document.createElement("div"); paginationContainer.classList.add("pagination"); From 5889ad5ebbbd416c8aceecf21c4c571775b1b4f9 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 05:33:54 +0000 Subject: [PATCH 10/19] feat: add Dark Mode support and fix white frame issue in TableWidget - Update TableWidget CSS with variables and media queries for dark mode. - Pass CSS content via a new 'css_styles' traitlet to ensure reliable loading. - Implement robust JavaScript logic to detect VS Code dark themes and recursively clear ancestor backgrounds, eliminating the 'white frame' issue. - Reformat CSS and JS files to comply with project style guides. - Add unit test for CSS traitlet population. --- bigframes/display/anywidget.py | 4 + bigframes/display/html.py | 2 +- bigframes/display/table_widget.css | 75 +++++++------- bigframes/display/table_widget.js | 119 +++++++++++++---------- tests/unit/display/test_anywidget_css.py | 40 ++++++++ 5 files changed, 148 insertions(+), 92 deletions(-) create mode 100644 tests/unit/display/test_anywidget_css.py diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index a81aff9080..2e84550087 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -68,6 +68,7 @@ class TableWidget(_WIDGET_BASE): page_size = traitlets.Int(0).tag(sync=True) row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) table_html = traitlets.Unicode("").tag(sync=True) + css_styles = traitlets.Unicode("").tag(sync=True) sort_column = traitlets.Unicode("").tag(sync=True) sort_ascending = traitlets.Bool(True).tag(sync=True) orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True) @@ -119,6 +120,9 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): else: self.orderable_columns = [] + # Load CSS manually to ensure it's available for JS injection if needed + self.css_styles = self._css + self._initial_load() # Signals to the frontend that the initial data load is complete. diff --git a/bigframes/display/html.py b/bigframes/display/html.py index 0234829067..912f1d7e3a 100644 --- a/bigframes/display/html.py +++ b/bigframes/display/html.py @@ -49,7 +49,7 @@ def render_html( ) -> str: """Render a pandas DataFrame to HTML with specific styling.""" orderable_columns = orderable_columns or [] - classes = "dataframe table" + classes = "dataframe table table-striped table-hover" table_html_parts = [f'
'] table_html_parts.append(_render_table_header(dataframe, orderable_columns)) table_html_parts.append(_render_table_body(dataframe)) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index d4c11f6096..f73333d6f5 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -17,26 +17,25 @@ .bigframes-widget { /* Default Light Mode Variables */ --bf-bg: white; - --bf-fg: black; - --bf-header-bg: #f5f5f5; - --bf-row-odd-bg: white; - --bf-row-even-bg: #f5f5f5; --bf-border-color: #ccc; - --bf-null-fg: gray; --bf-error-bg: #fbe; --bf-error-border: red; --bf-error-fg: black; + --bf-fg: black; + --bf-header-bg: #f5f5f5; + --bf-null-fg: gray; + --bf-row-even-bg: #f5f5f5; + --bf-row-odd-bg: white; - display: flex; - flex-direction: column; - /* Double class to increase specificity + !important to override framework styles */ background-color: var(--bf-bg) !important; + box-sizing: border-box; color: var(--bf-fg) !important; + display: flex; + flex-direction: column; font-family: "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - padding: 0 !important; margin: 0 !important; - box-sizing: border-box; + padding: 0 !important; } .bigframes-widget * { @@ -47,48 +46,48 @@ @media (prefers-color-scheme: dark) { .bigframes-widget { --bf-bg: var(--vscode-editor-background, #202124); - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-row-odd-bg: #383838; - --bf-row-even-bg: #202124; --bf-border-color: #444; - --bf-null-fg: #aaa; --bf-error-bg: #511; --bf-error-border: #f88; --bf-error-fg: #fcc; + --bf-fg: white; + --bf-header-bg: var(--vscode-editor-background, black); + --bf-null-fg: #aaa; + --bf-row-even-bg: #202124; + --bf-row-odd-bg: #383838; } } /* Dark Mode Overrides via Explicit Class */ .bigframes-widget.bigframes-dark-mode.bigframes-dark-mode { --bf-bg: var(--vscode-editor-background, #202124); - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-row-odd-bg: #383838; - --bf-row-even-bg: #202124; --bf-border-color: #444; - --bf-null-fg: #aaa; --bf-error-bg: #511; --bf-error-border: #f88; --bf-error-fg: #fcc; + --bf-fg: white; + --bf-header-bg: var(--vscode-editor-background, black); + --bf-null-fg: #aaa; + --bf-row-even-bg: #202124; + --bf-row-odd-bg: #383838; } .bigframes-widget .table-container { + background-color: var(--bf-bg); + margin: 0; max-height: 620px; overflow: auto; - background-color: var(--bf-bg); padding: 0; - margin: 0; } .bigframes-widget .footer { align-items: center; + background-color: var(--bf-bg); + color: var(--bf-fg); display: flex; font-size: 0.8rem; justify-content: space-between; padding: 8px; - background-color: var(--bf-bg); - color: var(--bf-fg); } .bigframes-widget .footer > * { @@ -126,17 +125,17 @@ .bigframes-widget table, .bigframes-widget table.dataframe { - border-collapse: collapse !important; - border-spacing: 0 !important; - text-align: left; - width: auto !important; /* Fix stretching */ background-color: var(--bf-bg) !important; - color: var(--bf-fg) !important; /* Explicitly override border="1" defaults */ border: 1px solid var(--bf-border-color) !important; + border-collapse: collapse !important; + border-spacing: 0 !important; box-shadow: none !important; - outline: none !important; + color: var(--bf-fg) !important; margin: 0 !important; + outline: none !important; + text-align: left; + width: auto !important; /* Fix stretching */ } .bigframes-widget tr { @@ -145,19 +144,19 @@ .bigframes-widget th { background-color: var(--bf-header-bg) !important; + border: 1px solid var(--bf-border-color) !important; color: var(--bf-fg) !important; padding: 0; position: sticky; text-align: left; top: 0; z-index: 1; - border: 1px solid var(--bf-border-color) !important; } .bigframes-widget td { - padding: 0.5em; border: 1px solid var(--bf-border-color) !important; color: var(--bf-fg) !important; + padding: 0.5em; } .bigframes-widget table tbody tr:nth-child(odd), @@ -189,17 +188,17 @@ } .bigframes-widget button { + background-color: transparent; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; cursor: pointer; display: inline-block; + padding: 2px 8px; text-align: center; text-decoration: none; user-select: none; vertical-align: middle; - color: inherit; - background-color: transparent; - border: 1px solid currentColor; - border-radius: 4px; - padding: 2px 8px; } .bigframes-widget button:disabled { @@ -210,8 +209,8 @@ .bigframes-widget .bigframes-error-message { background-color: var(--bf-error-bg); border: 1px solid var(--bf-error-border); - color: var(--bf-error-fg); border-radius: 4px; + color: var(--bf-error-fg); font-size: 14px; margin-bottom: 8px; padding: 8px; diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index de3da0ec01..7340d34a16 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -23,6 +23,7 @@ const ModelProperty = { SORT_ASCENDING: "sort_ascending", SORT_COLUMN: "sort_column", TABLE_HTML: "table_html", + CSS_STYLES: "css_styles", }; const Event = { @@ -36,10 +37,16 @@ const Event = { * @param {{ model: any, el: !HTMLElement }} props - The widget properties. */ function render({ model, el }) { - // Main container with a unique class for CSS scoping el.classList.add("bigframes-widget"); - // Add error message container at the top + // Inject CSS styles passed from the backend. + const cssStyles = model.get(ModelProperty.CSS_STYLES); + if (cssStyles) { + const style = document.createElement("style"); + style.textContent = cssStyles; + el.appendChild(style); + } + const errorContainer = document.createElement("div"); errorContainer.classList.add("error-message"); @@ -48,10 +55,50 @@ function render({ model, el }) { const footer = document.createElement("footer"); footer.classList.add("footer"); - // Theme detection logic + /** + * Adjusts container styles to prevent white frames in dark mode environments. + * @param {boolean} isDark - Whether dark mode is active. + */ + function setContainerStyles(isDark) { + const body = document.body; + if (isDark) { + // Clear ancestor backgrounds to reveal the dark body background + try { + let parent = el.parentElement; + while (parent && parent !== body) { + parent.style.setProperty( + "background-color", + "transparent", + "important", + ); + parent.style.setProperty("padding", "0", "important"); + parent = parent.parentElement; + } + } catch (e) {} + + // Force body and html to dark background to cover full iframe area + if (body) { + body.style.setProperty("background-color", "#202124", "important"); + body.style.setProperty("margin", "0", "important"); + document.documentElement.style.setProperty( + "background-color", + "#202124", + "important", + ); + } + } else { + // Cleanup styles when switching back to light mode + if (body) { + body.style.removeProperty("background-color"); + body.style.removeProperty("margin"); + document.documentElement.style.removeProperty("background-color"); + } + } + } + + /** Detects theme and applies necessary style overrides. */ function updateTheme() { const body = document.body; - // Check for common dark mode indicators in VS Code / Jupyter const isDark = body.classList.contains("vscode-dark") || body.classList.contains("theme-dark") || @@ -60,15 +107,19 @@ function render({ model, el }) { if (isDark) { el.classList.add("bigframes-dark-mode"); + el.style.colorScheme = "dark"; + setContainerStyles(true); } else { el.classList.remove("bigframes-dark-mode"); + el.style.colorScheme = "light"; + setContainerStyles(false); } } - // Initial check updateTheme(); + // Re-check after mount to ensure parent styling is applied. + setTimeout(updateTheme, 300); - // Observe body attribute changes to react to theme switching const observer = new MutationObserver(updateTheme); observer.observe(document.body, { attributes: true, @@ -95,7 +146,6 @@ function render({ model, el }) { nextPage.textContent = ">"; pageSizeLabel.textContent = "Page size:"; - // Page size options const pageSizes = [10, 25, 50, 100]; for (const size of pageSizes) { const option = document.createElement("option"); @@ -107,103 +157,77 @@ function render({ model, el }) { pageSizeInput.appendChild(option); } - /** Updates the footer states and page label based on the model. */ function updateButtonStates() { const currentPage = model.get(ModelProperty.PAGE); const pageSize = model.get(ModelProperty.PAGE_SIZE); const rowCount = model.get(ModelProperty.ROW_COUNT); if (rowCount === null) { - // Unknown total rows rowCountLabel.textContent = "Total rows unknown"; - pageIndicator.textContent = `Page ${( - currentPage + 1 - ).toLocaleString()} of many`; + pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; prevPage.disabled = currentPage === 0; - nextPage.disabled = false; // Allow navigation until we hit the end + nextPage.disabled = false; } else if (rowCount === 0) { - // Empty dataset rowCountLabel.textContent = "0 total rows"; pageIndicator.textContent = "Page 1 of 1"; prevPage.disabled = true; nextPage.disabled = true; } else { - // Known total rows const totalPages = Math.ceil(rowCount / pageSize); rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - pageIndicator.textContent = `Page ${( - currentPage + 1 - ).toLocaleString()} of ${totalPages.toLocaleString()}`; + pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; prevPage.disabled = currentPage === 0; nextPage.disabled = currentPage >= totalPages - 1; } pageSizeInput.value = pageSize; } - /** - * Handles page navigation. - * @param {number} direction - The direction to navigate (-1 for previous, 1 for next). - */ function handlePageChange(direction) { const currentPage = model.get(ModelProperty.PAGE); model.set(ModelProperty.PAGE, currentPage + direction); model.save_changes(); } - /** - * Handles page size changes. - * @param {number} newSize - The new page size. - */ function handlePageSizeChange(newSize) { model.set(ModelProperty.PAGE_SIZE, newSize); - model.set(ModelProperty.PAGE, 0); // Reset to first page + model.set(ModelProperty.PAGE, 0); model.save_changes(); } - /** Updates the HTML in the table container and refreshes button states. */ function handleTableHTMLChange() { - // Note: Using innerHTML is safe here because the content is generated - // by a trusted backend (DataFrame.to_html). tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML); - // Get sortable columns from backend const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS); const currentSortColumn = model.get(ModelProperty.SORT_COLUMN); const currentSortAscending = model.get(ModelProperty.SORT_ASCENDING); - // Add click handlers to column headers for sorting const headers = tableContainer.querySelectorAll("th"); headers.forEach((header) => { const headerDiv = header.querySelector("div"); const columnName = headerDiv.textContent.trim(); - // Only add sorting UI for sortable columns if (columnName && sortableColumns.includes(columnName)) { header.style.cursor = "pointer"; - // Create a span for the indicator const indicatorSpan = document.createElement("span"); indicatorSpan.classList.add("sort-indicator"); indicatorSpan.style.paddingLeft = "5px"; - // Determine sort indicator and initial visibility - let indicator = "●"; // Default: unsorted (dot) + let indicator = "●"; if (currentSortColumn === columnName) { indicator = currentSortAscending ? "▲" : "▼"; - indicatorSpan.style.visibility = "visible"; // Sorted arrows always visible + indicatorSpan.style.visibility = "visible"; } else { - indicatorSpan.style.visibility = "hidden"; // Unsorted dot hidden by default + indicatorSpan.style.visibility = "hidden"; } indicatorSpan.textContent = indicator; - // Add indicator to the header, replacing the old one if it exists const existingIndicator = headerDiv.querySelector(".sort-indicator"); if (existingIndicator) { headerDiv.removeChild(existingIndicator); } headerDiv.appendChild(indicatorSpan); - // Add hover effects for unsorted columns only header.addEventListener("mouseover", () => { if (currentSortColumn !== columnName) { indicatorSpan.style.visibility = "visible"; @@ -215,19 +239,15 @@ function render({ model, el }) { } }); - // Add click handler for three-state toggle header.addEventListener(Event.CLICK, () => { if (currentSortColumn === columnName) { if (currentSortAscending) { - // Currently ascending → switch to descending model.set(ModelProperty.SORT_ASCENDING, false); } else { - // Currently descending → clear sort (back to unsorted) model.set(ModelProperty.SORT_COLUMN, ""); model.set(ModelProperty.SORT_ASCENDING, true); } } else { - // Not currently sorted → sort ascending model.set(ModelProperty.SORT_COLUMN, columnName); model.set(ModelProperty.SORT_ASCENDING, true); } @@ -239,7 +259,6 @@ function render({ model, el }) { updateButtonStates(); } - // Add error message handler function handleErrorMessageChange() { const errorMsg = model.get(ModelProperty.ERROR_MESSAGE); if (errorMsg) { @@ -250,7 +269,6 @@ function render({ model, el }) { } } - // Add event listeners prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1)); nextPage.addEventListener(Event.CLICK, () => handlePageChange(1)); pageSizeInput.addEventListener(Event.CHANGE, (e) => { @@ -259,24 +277,20 @@ function render({ model, el }) { handlePageSizeChange(newSize); } }); + model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange); model.on(`change:_initial_load_complete`, (val) => { - if (val) { - updateButtonStates(); - } + if (val) updateButtonStates(); }); model.on(`change:${ModelProperty.PAGE}`, updateButtonStates); - // Assemble the DOM paginationContainer.appendChild(prevPage); paginationContainer.appendChild(pageIndicator); paginationContainer.appendChild(nextPage); - pageSizeContainer.appendChild(pageSizeLabel); pageSizeContainer.appendChild(pageSizeInput); - footer.appendChild(rowCountLabel); footer.appendChild(paginationContainer); footer.appendChild(pageSizeContainer); @@ -285,7 +299,6 @@ function render({ model, el }) { el.appendChild(tableContainer); el.appendChild(footer); - // Initial render handleTableHTMLChange(); handleErrorMessageChange(); } diff --git a/tests/unit/display/test_anywidget_css.py b/tests/unit/display/test_anywidget_css.py new file mode 100644 index 0000000000..e2b43d7ce5 --- /dev/null +++ b/tests/unit/display/test_anywidget_css.py @@ -0,0 +1,40 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import unittest.mock as mock + +import bigframes.dataframe +from bigframes.display.anywidget import TableWidget + + +def test_css_styles_traitlet_is_populated(): + """Verify that css_styles traitlet is populated from the external CSS file.""" + # Mock the dataframe and its block + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) + mock_df.columns = ["col1"] + mock_df.dtypes = {"col1": "object"} + mock_block = mock.Mock() + mock_block.has_index = False + mock_df._block = mock_block + + # Mock _initial_load to avoid side effects + with mock.patch.object(TableWidget, "_initial_load"): + widget = TableWidget(mock_df) + + # Check that css_styles is not empty + assert widget.css_styles + + # Check that it contains expected CSS content (e.g. dark mode query) + assert "@media (prefers-color-scheme: dark)" in widget.css_styles + assert ".bigframes-widget" in widget.css_styles From f345f5eab26da04dab8f5c821bae4342fc59b7c2 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 05:40:34 +0000 Subject: [PATCH 11/19] chore: remove try catch --- bigframes/display/table_widget.css | 52 +++++++++++++++--------------- bigframes/display/table_widget.js | 26 ++++++--------- 2 files changed, 35 insertions(+), 43 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index f73333d6f5..7a7db2e958 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -14,7 +14,8 @@ * limitations under the License. */ -.bigframes-widget { +/* Increase specificity to override framework styles without !important */ +.bigframes-widget.bigframes-widget { /* Default Light Mode Variables */ --bf-bg: white; --bf-border-color: #ccc; @@ -27,15 +28,15 @@ --bf-row-even-bg: #f5f5f5; --bf-row-odd-bg: white; - background-color: var(--bf-bg) !important; + background-color: var(--bf-bg); box-sizing: border-box; - color: var(--bf-fg) !important; + color: var(--bf-fg); display: flex; flex-direction: column; font-family: "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - margin: 0 !important; - padding: 0 !important; + margin: 0; + padding: 0; } .bigframes-widget * { @@ -44,7 +45,7 @@ /* Dark Mode Overrides via Media Query */ @media (prefers-color-scheme: dark) { - .bigframes-widget { + .bigframes-widget.bigframes-widget { --bf-bg: var(--vscode-editor-background, #202124); --bf-border-color: #444; --bf-error-bg: #511; @@ -123,29 +124,28 @@ margin-right: 8px; } -.bigframes-widget table, +.bigframes-widget table.bigframes-widget-table, .bigframes-widget table.dataframe { - background-color: var(--bf-bg) !important; - /* Explicitly override border="1" defaults */ - border: 1px solid var(--bf-border-color) !important; - border-collapse: collapse !important; - border-spacing: 0 !important; - box-shadow: none !important; - color: var(--bf-fg) !important; - margin: 0 !important; - outline: none !important; + background-color: var(--bf-bg); + border: 1px solid var(--bf-border-color); + border-collapse: collapse; + border-spacing: 0; + box-shadow: none; + color: var(--bf-fg); + margin: 0; + outline: none; text-align: left; - width: auto !important; /* Fix stretching */ + width: auto; /* Fix stretching */ } .bigframes-widget tr { - border: none !important; + border: none; } .bigframes-widget th { - background-color: var(--bf-header-bg) !important; - border: 1px solid var(--bf-border-color) !important; - color: var(--bf-fg) !important; + background-color: var(--bf-header-bg); + border: 1px solid var(--bf-border-color); + color: var(--bf-fg); padding: 0; position: sticky; text-align: left; @@ -154,19 +154,19 @@ } .bigframes-widget td { - border: 1px solid var(--bf-border-color) !important; - color: var(--bf-fg) !important; + border: 1px solid var(--bf-border-color); + color: var(--bf-fg); padding: 0.5em; } .bigframes-widget table tbody tr:nth-child(odd), .bigframes-widget table tbody tr:nth-child(odd) td { - background-color: var(--bf-row-odd-bg) !important; + background-color: var(--bf-row-odd-bg); } .bigframes-widget table tbody tr:nth-child(even), .bigframes-widget table tbody tr:nth-child(even) td { - background-color: var(--bf-row-even-bg) !important; + background-color: var(--bf-row-even-bg); } .bigframes-widget .bf-header-content { @@ -229,5 +229,5 @@ } .bigframes-widget .debug-info { - border-top: 1px solid var(--bf-border-color) !important; + border-top: 1px solid var(--bf-border-color); } diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 7340d34a16..d7c0ce8ad5 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -62,28 +62,20 @@ function render({ model, el }) { function setContainerStyles(isDark) { const body = document.body; if (isDark) { - // Clear ancestor backgrounds to reveal the dark body background - try { - let parent = el.parentElement; - while (parent && parent !== body) { - parent.style.setProperty( - "background-color", - "transparent", - "important", - ); - parent.style.setProperty("padding", "0", "important"); - parent = parent.parentElement; - } - } catch (e) {} + // Clear background of ancestors to remove "white frame" from containers. + let parent = el.parentElement; + while (parent && parent !== document.body) { + parent.style.setProperty("background-color", "transparent"); + parent.style.setProperty("padding", "0"); + parent = parent.parentElement; + } - // Force body and html to dark background to cover full iframe area if (body) { - body.style.setProperty("background-color", "#202124", "important"); - body.style.setProperty("margin", "0", "important"); + body.style.setProperty("background-color", "#202124"); + body.style.setProperty("margin", "0"); document.documentElement.style.setProperty( "background-color", "#202124", - "important", ); } } else { From 2feb53d008a327040c4a022b099d861d65cd35fc Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Wed, 31 Dec 2025 05:42:19 +0000 Subject: [PATCH 12/19] feat: remove white boarder --- bigframes/display/table_widget.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index d7c0ce8ad5..d941a330f1 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -65,17 +65,22 @@ function render({ model, el }) { // Clear background of ancestors to remove "white frame" from containers. let parent = el.parentElement; while (parent && parent !== document.body) { - parent.style.setProperty("background-color", "transparent"); - parent.style.setProperty("padding", "0"); + parent.style.setProperty( + "background-color", + "transparent", + "important", + ); + parent.style.setProperty("padding", "0", "important"); parent = parent.parentElement; } if (body) { - body.style.setProperty("background-color", "#202124"); - body.style.setProperty("margin", "0"); + body.style.setProperty("background-color", "#202124", "important"); + body.style.setProperty("margin", "0", "important"); document.documentElement.style.setProperty( "background-color", "#202124", + "important", ); } } else { From 4ae0fe8a13d2ebe5520b80dba8eb37a2fc1da15a Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Fri, 2 Jan 2026 20:16:34 +0000 Subject: [PATCH 13/19] test: add a testcase --- tests/unit/display/test_anywidget.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py index 482e81d502..9f337b10aa 100644 --- a/tests/unit/display/test_anywidget.py +++ b/tests/unit/display/test_anywidget.py @@ -96,3 +96,27 @@ def test_css_contains_dark_mode_media_query(): with mock.patch.object(TableWidget, "_initial_load"): widget = TableWidget(mock_df) assert "@media (prefers-color-scheme: dark)" in widget._css + + +def test_css_styles_traitlet_is_populated(): + """Verify that css_styles traitlet is populated from the external CSS file.""" + from bigframes.display.anywidget import TableWidget + + # Mock the dataframe and its block + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) + mock_df.columns = ["col1"] + mock_df.dtypes = {"col1": "object"} + mock_block = mock.Mock() + mock_block.has_index = False + mock_df._block = mock_block + + # Mock _initial_load to avoid side effects + with mock.patch.object(TableWidget, "_initial_load"): + widget = TableWidget(mock_df) + + # Check that css_styles is not empty + assert widget.css_styles + + # Check that it contains expected CSS content (e.g. dark mode query) + assert "@media (prefers-color-scheme: dark)" in widget.css_styles + assert ".bigframes-widget" in widget.css_styles From 62897ec24e5dc4f7c867776e325c7140addf9002 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 5 Jan 2026 21:10:36 +0000 Subject: [PATCH 14/19] refactor(anywidget): Simplify dark mode implementation --- bigframes/display/anywidget.py | 4 -- bigframes/display/table_widget.js | 51 ------------------------ tests/unit/display/test_anywidget.py | 42 ------------------- tests/unit/display/test_anywidget_css.py | 40 ------------------- 4 files changed, 137 deletions(-) delete mode 100644 tests/unit/display/test_anywidget_css.py diff --git a/bigframes/display/anywidget.py b/bigframes/display/anywidget.py index 00c704cd0e..6a16a9f762 100644 --- a/bigframes/display/anywidget.py +++ b/bigframes/display/anywidget.py @@ -68,7 +68,6 @@ class TableWidget(_WIDGET_BASE): page_size = traitlets.Int(0).tag(sync=True) row_count = traitlets.Int(allow_none=True, default_value=None).tag(sync=True) table_html = traitlets.Unicode("").tag(sync=True) - css_styles = traitlets.Unicode("").tag(sync=True) sort_context = traitlets.List(traitlets.Dict(), default_value=[]).tag(sync=True) orderable_columns = traitlets.List(traitlets.Unicode(), []).tag(sync=True) _initial_load_complete = traitlets.Bool(False).tag(sync=True) @@ -119,9 +118,6 @@ def __init__(self, dataframe: bigframes.dataframe.DataFrame): else: self.orderable_columns = [] - # Load CSS manually to ensure it's available for JS injection if needed - self.css_styles = self._css - self._initial_load() # Signals to the frontend that the initial data load is complete. diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 3eab00f583..51d8a03405 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -22,7 +22,6 @@ const ModelProperty = { ROW_COUNT: "row_count", SORT_CONTEXT: "sort_context", TABLE_HTML: "table_html", - CSS_STYLES: "css_styles", }; const Event = { @@ -38,14 +37,6 @@ const Event = { function render({ model, el }) { el.classList.add("bigframes-widget"); - // Inject CSS styles passed from the backend. - const cssStyles = model.get(ModelProperty.CSS_STYLES); - if (cssStyles) { - const style = document.createElement("style"); - style.textContent = cssStyles; - el.appendChild(style); - } - const errorContainer = document.createElement("div"); errorContainer.classList.add("error-message"); @@ -54,44 +45,6 @@ function render({ model, el }) { const footer = document.createElement("footer"); footer.classList.add("footer"); - /** - * Adjusts container styles to prevent white frames in dark mode environments. - * @param {boolean} isDark - Whether dark mode is active. - */ - function setContainerStyles(isDark) { - const body = document.body; - if (isDark) { - // Clear background of ancestors to remove "white frame" from containers. - let parent = el.parentElement; - while (parent && parent !== document.body) { - parent.style.setProperty( - "background-color", - "transparent", - "important", - ); - parent.style.setProperty("padding", "0", "important"); - parent = parent.parentElement; - } - - if (body) { - body.style.setProperty("background-color", "#202124", "important"); - body.style.setProperty("margin", "0", "important"); - document.documentElement.style.setProperty( - "background-color", - "#202124", - "important", - ); - } - } else { - // Cleanup styles when switching back to light mode - if (body) { - body.style.removeProperty("background-color"); - body.style.removeProperty("margin"); - document.documentElement.style.removeProperty("background-color"); - } - } - } - /** Detects theme and applies necessary style overrides. */ function updateTheme() { const body = document.body; @@ -103,12 +56,8 @@ function render({ model, el }) { if (isDark) { el.classList.add("bigframes-dark-mode"); - el.style.colorScheme = "dark"; - setContainerStyles(true); } else { el.classList.remove("bigframes-dark-mode"); - el.style.colorScheme = "light"; - setContainerStyles(false); } } diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py index 14f0c0552f..a635697e20 100644 --- a/tests/unit/display/test_anywidget.py +++ b/tests/unit/display/test_anywidget.py @@ -80,48 +80,6 @@ def handler(signum, frame): signal.alarm(0) -def test_css_contains_dark_mode_media_query(): - from bigframes.display.anywidget import TableWidget - - mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) - # mock_df.columns and mock_df.dtypes are needed for __init__ - mock_df.columns = ["col1"] - mock_df.dtypes = {"col1": "object"} - - # Mock _block to avoid AttributeError during _set_table_html - mock_block = mock.Mock() - mock_block.has_index = False - mock_df._block = mock_block - - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df) - assert "@media (prefers-color-scheme: dark)" in widget._css - - -def test_css_styles_traitlet_is_populated(): - """Verify that css_styles traitlet is populated from the external CSS file.""" - from bigframes.display.anywidget import TableWidget - - # Mock the dataframe and its block - mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) - mock_df.columns = ["col1"] - mock_df.dtypes = {"col1": "object"} - mock_block = mock.Mock() - mock_block.has_index = False - mock_df._block = mock_block - - # Mock _initial_load to avoid side effects - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df) - - # Check that css_styles is not empty - assert widget.css_styles - - # Check that it contains expected CSS content (e.g. dark mode query) - assert "@media (prefers-color-scheme: dark)" in widget.css_styles - assert ".bigframes-widget" in widget.css_styles - - @pytest.fixture def mock_df(): df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) diff --git a/tests/unit/display/test_anywidget_css.py b/tests/unit/display/test_anywidget_css.py deleted file mode 100644 index e2b43d7ce5..0000000000 --- a/tests/unit/display/test_anywidget_css.py +++ /dev/null @@ -1,40 +0,0 @@ -# Copyright 2025 Google LLC -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import unittest.mock as mock - -import bigframes.dataframe -from bigframes.display.anywidget import TableWidget - - -def test_css_styles_traitlet_is_populated(): - """Verify that css_styles traitlet is populated from the external CSS file.""" - # Mock the dataframe and its block - mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) - mock_df.columns = ["col1"] - mock_df.dtypes = {"col1": "object"} - mock_block = mock.Mock() - mock_block.has_index = False - mock_df._block = mock_block - - # Mock _initial_load to avoid side effects - with mock.patch.object(TableWidget, "_initial_load"): - widget = TableWidget(mock_df) - - # Check that css_styles is not empty - assert widget.css_styles - - # Check that it contains expected CSS content (e.g. dark mode query) - assert "@media (prefers-color-scheme: dark)" in widget.css_styles - assert ".bigframes-widget" in widget.css_styles From 3f7145f1385f493d8ffff1834807d26c07ad2f0c Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 5 Jan 2026 21:15:52 +0000 Subject: [PATCH 15/19] test(anywidget): Add tests for dark mode --- tests/js/table_widget.test.js | 43 ++++++++++++++++++++++++++++ tests/unit/display/test_anywidget.py | 18 ++++++++++++ 2 files changed, 61 insertions(+) diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js index b3796905e5..52d4dc03aa 100644 --- a/tests/js/table_widget.test.js +++ b/tests/js/table_widget.test.js @@ -241,6 +241,49 @@ describe("TableWidget", () => { }); }); + describe("Theme detection", () => { + beforeEach(() => { + jest.useFakeTimers(); + // Mock the initial state for theme detection tests + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ""; + } + if (property === "row_count") { + return 100; + } + if (property === "error_message") { + return null; + } + if (property === "page_size") { + return 10; + } + if (property === "page") { + return 0; + } + return null; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.classList.remove("vscode-dark"); + }); + + it("should add bigframes-dark-mode class in dark mode", () => { + document.body.classList.add("vscode-dark"); + render({ model, el }); + jest.runAllTimers(); + expect(el.classList.contains("bigframes-dark-mode")).toBe(true); + }); + + it("should not add bigframes-dark-mode class in light mode", () => { + render({ model, el }); + jest.runAllTimers(); + expect(el.classList.contains("bigframes-dark-mode")).toBe(false); + }); + }); + it("should render the series as a table with an index and one value column", () => { // Mock the initial state model.get.mockImplementation((property) => { diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py index a635697e20..a02843b889 100644 --- a/tests/unit/display/test_anywidget.py +++ b/tests/unit/display/test_anywidget.py @@ -80,6 +80,24 @@ def handler(signum, frame): signal.alarm(0) +def test_css_contains_dark_mode_media_query(): + from bigframes.display.anywidget import TableWidget + + mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) + # mock_df.columns and mock_df.dtypes are needed for __init__ + mock_df.columns = ["col1"] + mock_df.dtypes = {"col1": "object"} + + # Mock _block to avoid AttributeError during _set_table_html + mock_block = mock.Mock() + mock_block.has_index = False + mock_df._block = mock_block + + with mock.patch.object(TableWidget, "_initial_load"): + widget = TableWidget(mock_df) + assert "@media (prefers-color-scheme: dark)" in widget._css + + @pytest.fixture def mock_df(): df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) From 0a77dc20d5e76b03848a67d63e84669cff714ad5 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 5 Jan 2026 21:20:30 +0000 Subject: [PATCH 16/19] style(css): Apply coding style guide to table_widget.css --- bigframes/display/table_widget.css | 258 ++++++++++++++--------------- 1 file changed, 129 insertions(+), 129 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 7a7db2e958..6d691e3455 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -16,218 +16,218 @@ /* Increase specificity to override framework styles without !important */ .bigframes-widget.bigframes-widget { - /* Default Light Mode Variables */ - --bf-bg: white; - --bf-border-color: #ccc; - --bf-error-bg: #fbe; - --bf-error-border: red; - --bf-error-fg: black; - --bf-fg: black; - --bf-header-bg: #f5f5f5; - --bf-null-fg: gray; - --bf-row-even-bg: #f5f5f5; - --bf-row-odd-bg: white; - - background-color: var(--bf-bg); - box-sizing: border-box; - color: var(--bf-fg); - display: flex; - flex-direction: column; - font-family: - "-apple-system", "BlinkMacSystemFont", "Segoe UI", "Roboto", sans-serif; - margin: 0; - padding: 0; + /* Default Light Mode Variables */ + --bf-bg: white; + --bf-border-color: #ccc; + --bf-error-bg: #fbe; + --bf-error-border: red; + --bf-error-fg: black; + --bf-fg: black; + --bf-header-bg: #f5f5f5; + --bf-null-fg: gray; + --bf-row-even-bg: #f5f5f5; + --bf-row-odd-bg: white; + + background-color: var(--bf-bg); + box-sizing: border-box; + color: var(--bf-fg); + display: flex; + flex-direction: column; + font-family: + '-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', sans-serif; + margin: 0; + padding: 0; } .bigframes-widget * { - box-sizing: border-box; + box-sizing: border-box; } /* Dark Mode Overrides via Media Query */ @media (prefers-color-scheme: dark) { - .bigframes-widget.bigframes-widget { - --bf-bg: var(--vscode-editor-background, #202124); - --bf-border-color: #444; - --bf-error-bg: #511; - --bf-error-border: #f88; - --bf-error-fg: #fcc; - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-null-fg: #aaa; - --bf-row-even-bg: #202124; - --bf-row-odd-bg: #383838; - } + .bigframes-widget.bigframes-widget { + --bf-bg: var(--vscode-editor-background, #202124); + --bf-border-color: #444; + --bf-error-bg: #511; + --bf-error-border: #f88; + --bf-error-fg: #fcc; + --bf-fg: white; + --bf-header-bg: var(--vscode-editor-background, black); + --bf-null-fg: #aaa; + --bf-row-even-bg: #202124; + --bf-row-odd-bg: #383838; + } } /* Dark Mode Overrides via Explicit Class */ .bigframes-widget.bigframes-dark-mode.bigframes-dark-mode { - --bf-bg: var(--vscode-editor-background, #202124); - --bf-border-color: #444; - --bf-error-bg: #511; - --bf-error-border: #f88; - --bf-error-fg: #fcc; - --bf-fg: white; - --bf-header-bg: var(--vscode-editor-background, black); - --bf-null-fg: #aaa; - --bf-row-even-bg: #202124; - --bf-row-odd-bg: #383838; + --bf-bg: var(--vscode-editor-background, #202124); + --bf-border-color: #444; + --bf-error-bg: #511; + --bf-error-border: #f88; + --bf-error-fg: #fcc; + --bf-fg: white; + --bf-header-bg: var(--vscode-editor-background, black); + --bf-null-fg: #aaa; + --bf-row-even-bg: #202124; + --bf-row-odd-bg: #383838; } .bigframes-widget .table-container { - background-color: var(--bf-bg); - margin: 0; - max-height: 620px; - overflow: auto; - padding: 0; + background-color: var(--bf-bg); + margin: 0; + max-height: 620px; + overflow: auto; + padding: 0; } .bigframes-widget .footer { - align-items: center; - background-color: var(--bf-bg); - color: var(--bf-fg); - display: flex; - font-size: 0.8rem; - justify-content: space-between; - padding: 8px; + align-items: center; + background-color: var(--bf-bg); + color: var(--bf-fg); + display: flex; + font-size: 0.8rem; + justify-content: space-between; + padding: 8px; } .bigframes-widget .footer > * { - flex: 1; + flex: 1; } .bigframes-widget .pagination { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - justify-content: center; - padding: 4px; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: center; + padding: 4px; } .bigframes-widget .page-indicator { - margin: 0 8px; + margin: 0 8px; } .bigframes-widget .row-count { - margin: 0 8px; + margin: 0 8px; } .bigframes-widget .page-size { - align-items: center; - display: flex; - flex-direction: row; - gap: 4px; - justify-content: end; + align-items: center; + display: flex; + flex-direction: row; + gap: 4px; + justify-content: end; } .bigframes-widget .page-size label { - margin-right: 8px; + margin-right: 8px; } .bigframes-widget table.bigframes-widget-table, .bigframes-widget table.dataframe { - background-color: var(--bf-bg); - border: 1px solid var(--bf-border-color); - border-collapse: collapse; - border-spacing: 0; - box-shadow: none; - color: var(--bf-fg); - margin: 0; - outline: none; - text-align: left; - width: auto; /* Fix stretching */ + background-color: var(--bf-bg); + border: 1px solid var(--bf-border-color); + border-collapse: collapse; + border-spacing: 0; + box-shadow: none; + color: var(--bf-fg); + margin: 0; + outline: none; + text-align: left; + width: auto; /* Fix stretching */ } .bigframes-widget tr { - border: none; + border: none; } .bigframes-widget th { - background-color: var(--bf-header-bg); - border: 1px solid var(--bf-border-color); - color: var(--bf-fg); - padding: 0; - position: sticky; - text-align: left; - top: 0; - z-index: 1; + background-color: var(--bf-header-bg); + border: 1px solid var(--bf-border-color); + color: var(--bf-fg); + padding: 0; + position: sticky; + text-align: left; + top: 0; + z-index: 1; } .bigframes-widget td { - border: 1px solid var(--bf-border-color); - color: var(--bf-fg); - padding: 0.5em; + border: 1px solid var(--bf-border-color); + color: var(--bf-fg); + padding: 0.5em; } .bigframes-widget table tbody tr:nth-child(odd), .bigframes-widget table tbody tr:nth-child(odd) td { - background-color: var(--bf-row-odd-bg); + background-color: var(--bf-row-odd-bg); } .bigframes-widget table tbody tr:nth-child(even), .bigframes-widget table tbody tr:nth-child(even) td { - background-color: var(--bf-row-even-bg); + background-color: var(--bf-row-even-bg); } .bigframes-widget .bf-header-content { - box-sizing: border-box; - height: 100%; - overflow: auto; - padding: 0.5em; - resize: horizontal; - width: 100%; + box-sizing: border-box; + height: 100%; + overflow: auto; + padding: 0.5em; + resize: horizontal; + width: 100%; } .bigframes-widget th .sort-indicator { - padding-left: 4px; - visibility: hidden; + padding-left: 4px; + visibility: hidden; } .bigframes-widget th:hover .sort-indicator { - visibility: visible; + visibility: visible; } .bigframes-widget button { - background-color: transparent; - border: 1px solid currentColor; - border-radius: 4px; - color: inherit; - cursor: pointer; - display: inline-block; - padding: 2px 8px; - text-align: center; - text-decoration: none; - user-select: none; - vertical-align: middle; + background-color: transparent; + border: 1px solid currentColor; + border-radius: 4px; + color: inherit; + cursor: pointer; + display: inline-block; + padding: 2px 8px; + text-align: center; + text-decoration: none; + user-select: none; + vertical-align: middle; } .bigframes-widget button:disabled { - opacity: 0.65; - pointer-events: none; + opacity: 0.65; + pointer-events: none; } .bigframes-widget .bigframes-error-message { - background-color: var(--bf-error-bg); - border: 1px solid var(--bf-error-border); - border-radius: 4px; - color: var(--bf-error-fg); - font-size: 14px; - margin-bottom: 8px; - padding: 8px; + background-color: var(--bf-error-bg); + border: 1px solid var(--bf-error-border); + border-radius: 4px; + color: var(--bf-error-fg); + font-size: 14px; + margin-bottom: 8px; + padding: 8px; } .bigframes-widget .cell-align-right { - text-align: right; + text-align: right; } .bigframes-widget .cell-align-left { - text-align: left; + text-align: left; } .bigframes-widget .null-value { - color: var(--bf-null-fg); + color: var(--bf-null-fg); } .bigframes-widget .debug-info { - border-top: 1px solid var(--bf-border-color); -} + border-top: 1px solid var(--bf-border-color); +} \ No newline at end of file From 9f16b312ea1d9c43288191fd56f746c73f4290de Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 5 Jan 2026 21:25:13 +0000 Subject: [PATCH 17/19] style(js): Use 2 spaces for indentation --- tests/js/table_widget.test.js | 637 +++++++++++++++++----------------- 1 file changed, 318 insertions(+), 319 deletions(-) diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js index 52d4dc03aa..b42ca65518 100644 --- a/tests/js/table_widget.test.js +++ b/tests/js/table_widget.test.js @@ -15,325 +15,324 @@ */ import { jest } from "@jest/globals"; -import { JSDOM } from "jsdom"; describe("TableWidget", () => { - let model; - let el; - let render; - - beforeEach(async () => { - jest.resetModules(); - document.body.innerHTML = "
"; - el = document.body.querySelector("div"); - - const tableWidget = ( - await import("../../bigframes/display/table_widget.js") - ).default; - render = tableWidget.render; - - model = { - get: jest.fn(), - set: jest.fn(), - save_changes: jest.fn(), - on: jest.fn(), - }; - }); - - it("should have a render function", () => { - expect(render).toBeDefined(); - }); - - describe("render", () => { - it("should create the basic structure", () => { - // Mock the initial state - model.get.mockImplementation((property) => { - if (property === "table_html") { - return ""; - } - if (property === "row_count") { - return 100; - } - if (property === "error_message") { - return null; - } - if (property === "page_size") { - return 10; - } - if (property === "page") { - return 0; - } - return null; - }); - - render({ model, el }); - - expect(el.classList.contains("bigframes-widget")).toBe(true); - expect(el.querySelector(".error-message")).not.toBeNull(); - expect(el.querySelector("div")).not.toBeNull(); - expect(el.querySelector("div:nth-child(3)")).not.toBeNull(); - }); - - it("should sort when a sortable column is clicked", () => { - // Mock the initial state - model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
"; - } - if (property === "orderable_columns") { - return ["col1"]; - } - if (property === "sort_context") { - return []; - } - return null; - }); - - render({ model, el }); - - // Manually trigger the table_html change handler - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", - )[1]; - tableHtmlChangeHandler(); - - const header = el.querySelector("th"); - header.click(); - - expect(model.set).toHaveBeenCalledWith("sort_context", [ - { column: "col1", ascending: true }, - ]); - expect(model.save_changes).toHaveBeenCalled(); - }); - - it("should reverse sort direction when a sorted column is clicked", () => { - // Mock the initial state - model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
"; - } - if (property === "orderable_columns") { - return ["col1"]; - } - if (property === "sort_context") { - return [{ column: "col1", ascending: true }]; - } - return null; - }); - - render({ model, el }); - - // Manually trigger the table_html change handler - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", - )[1]; - tableHtmlChangeHandler(); - - const header = el.querySelector("th"); - header.click(); - - expect(model.set).toHaveBeenCalledWith("sort_context", [ - { column: "col1", ascending: false }, - ]); - expect(model.save_changes).toHaveBeenCalled(); - }); - - it("should clear sort when a descending sorted column is clicked", () => { - // Mock the initial state - model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
"; - } - if (property === "orderable_columns") { - return ["col1"]; - } - if (property === "sort_context") { - return [{ column: "col1", ascending: false }]; - } - return null; - }); - - render({ model, el }); - - // Manually trigger the table_html change handler - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", - )[1]; - tableHtmlChangeHandler(); - - const header = el.querySelector("th"); - header.click(); - - expect(model.set).toHaveBeenCalledWith("sort_context", []); - expect(model.save_changes).toHaveBeenCalled(); - }); - - it("should display the correct sort indicator", () => { - // Mock the initial state - model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
col2
"; - } - if (property === "orderable_columns") { - return ["col1", "col2"]; - } - if (property === "sort_context") { - return [{ column: "col1", ascending: true }]; - } - return null; - }); - - render({ model, el }); - - // Manually trigger the table_html change handler - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", - )[1]; - tableHtmlChangeHandler(); - - const headers = el.querySelectorAll("th"); - const indicator1 = headers[0].querySelector(".sort-indicator"); - const indicator2 = headers[1].querySelector(".sort-indicator"); - - expect(indicator1.textContent).toBe("▲"); - expect(indicator2.textContent).toBe("●"); - }); - - it("should add a column to sort when Shift+Click is used", () => { - // Mock the initial state: already sorted by col1 asc - model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
col2
"; - } - if (property === "orderable_columns") { - return ["col1", "col2"]; - } - if (property === "sort_context") { - return [{ column: "col1", ascending: true }]; - } - return null; - }); - - render({ model, el }); - - // Manually trigger the table_html change handler - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", - )[1]; - tableHtmlChangeHandler(); - - const headers = el.querySelectorAll("th"); - const header2 = headers[1]; // col2 - - // Simulate Shift+Click - const clickEvent = new MouseEvent("click", { - bubbles: true, - cancelable: true, - shiftKey: true, - }); - header2.dispatchEvent(clickEvent); - - expect(model.set).toHaveBeenCalledWith("sort_context", [ - { column: "col1", ascending: true }, - { column: "col2", ascending: true }, - ]); - expect(model.save_changes).toHaveBeenCalled(); - }); - }); - - describe("Theme detection", () => { - beforeEach(() => { - jest.useFakeTimers(); - // Mock the initial state for theme detection tests - model.get.mockImplementation((property) => { - if (property === "table_html") { - return ""; - } - if (property === "row_count") { - return 100; - } - if (property === "error_message") { - return null; - } - if (property === "page_size") { - return 10; - } - if (property === "page") { - return 0; - } - return null; - }); - }); - - afterEach(() => { - jest.useRealTimers(); - document.body.classList.remove("vscode-dark"); - }); - - it("should add bigframes-dark-mode class in dark mode", () => { - document.body.classList.add("vscode-dark"); - render({ model, el }); - jest.runAllTimers(); - expect(el.classList.contains("bigframes-dark-mode")).toBe(true); - }); - - it("should not add bigframes-dark-mode class in light mode", () => { - render({ model, el }); - jest.runAllTimers(); - expect(el.classList.contains("bigframes-dark-mode")).toBe(false); - }); - }); - - it("should render the series as a table with an index and one value column", () => { - // Mock the initial state - model.get.mockImplementation((property) => { - if (property === "table_html") { - return ` -
-
- - - - - - - - - - - - - - - - - -
value
0a
1b
-
-
`; - } - if (property === "orderable_columns") { - return []; - } - return null; - }); - - render({ model, el }); - - // Manually trigger the table_html change handler - const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", - )[1]; - tableHtmlChangeHandler(); - - // Check that the table has two columns - const headers = el.querySelectorAll( - ".paginated-table-container .col-header-name", - ); - expect(headers).toHaveLength(2); - - // Check that the headers are an empty string (for the index) and "value" - expect(headers[0].textContent).toBe(""); - expect(headers[1].textContent).toBe("value"); - }); + let model; + let el; + let render; + + beforeEach(async () => { + jest.resetModules(); + document.body.innerHTML = "
"; + el = document.body.querySelector("div"); + + const tableWidget = ( + await import("../../bigframes/display/table_widget.js") + ).default; + render = tableWidget.render; + + model = { + get: jest.fn(), + set: jest.fn(), + save_changes: jest.fn(), + on: jest.fn(), + }; + }); + + it("should have a render function", () => { + expect(render).toBeDefined(); + }); + + describe("render", () => { + it("should create the basic structure", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ""; + } + if (property === "row_count") { + return 100; + } + if (property === "error_message") { + return null; + } + if (property === "page_size") { + return 10; + } + if (property === "page") { + return 0; + } + return null; + }); + + render({ model, el }); + + expect(el.classList.contains("bigframes-widget")).toBe(true); + expect(el.querySelector(".error-message")).not.toBeNull(); + expect(el.querySelector("div")).not.toBeNull(); + expect(el.querySelector("div:nth-child(3)")).not.toBeNull(); + }); + + it("should sort when a sortable column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_context") { + return []; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_context", [ + { column: "col1", ascending: true }, + ]); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should reverse sort direction when a sorted column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_context") { + return [{ column: "col1", ascending: true }]; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_context", [ + { column: "col1", ascending: false }, + ]); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should clear sort when a descending sorted column is clicked", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
"; + } + if (property === "orderable_columns") { + return ["col1"]; + } + if (property === "sort_context") { + return [{ column: "col1", ascending: false }]; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const header = el.querySelector("th"); + header.click(); + + expect(model.set).toHaveBeenCalledWith("sort_context", []); + expect(model.save_changes).toHaveBeenCalled(); + }); + + it("should display the correct sort indicator", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
col2
"; + } + if (property === "orderable_columns") { + return ["col1", "col2"]; + } + if (property === "sort_context") { + return [{ column: "col1", ascending: true }]; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const headers = el.querySelectorAll("th"); + const indicator1 = headers[0].querySelector(".sort-indicator"); + const indicator2 = headers[1].querySelector(".sort-indicator"); + + expect(indicator1.textContent).toBe("▲"); + expect(indicator2.textContent).toBe("●"); + }); + + it("should add a column to sort when Shift+Click is used", () => { + // Mock the initial state: already sorted by col1 asc + model.get.mockImplementation((property) => { + if (property === "table_html") { + return "
col1
col2
"; + } + if (property === "orderable_columns") { + return ["col1", "col2"]; + } + if (property === "sort_context") { + return [{ column: "col1", ascending: true }]; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + const headers = el.querySelectorAll("th"); + const header2 = headers[1]; // col2 + + // Simulate Shift+Click + const clickEvent = new MouseEvent("click", { + bubbles: true, + cancelable: true, + shiftKey: true, + }); + header2.dispatchEvent(clickEvent); + + expect(model.set).toHaveBeenCalledWith("sort_context", [ + { column: "col1", ascending: true }, + { column: "col2", ascending: true }, + ]); + expect(model.save_changes).toHaveBeenCalled(); + }); + }); + + describe("Theme detection", () => { + beforeEach(() => { + jest.useFakeTimers(); + // Mock the initial state for theme detection tests + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ""; + } + if (property === "row_count") { + return 100; + } + if (property === "error_message") { + return null; + } + if (property === "page_size") { + return 10; + } + if (property === "page") { + return 0; + } + return null; + }); + }); + + afterEach(() => { + jest.useRealTimers(); + document.body.classList.remove("vscode-dark"); + }); + + it("should add bigframes-dark-mode class in dark mode", () => { + document.body.classList.add("vscode-dark"); + render({ model, el }); + jest.runAllTimers(); + expect(el.classList.contains("bigframes-dark-mode")).toBe(true); + }); + + it("should not add bigframes-dark-mode class in light mode", () => { + render({ model, el }); + jest.runAllTimers(); + expect(el.classList.contains("bigframes-dark-mode")).toBe(false); + }); + }); + + it("should render the series as a table with an index and one value column", () => { + // Mock the initial state + model.get.mockImplementation((property) => { + if (property === "table_html") { + return ` +
+
+ + + + + + + + + + + + + + + + + +
value
0a
1b
+
+
`; + } + if (property === "orderable_columns") { + return []; + } + return null; + }); + + render({ model, el }); + + // Manually trigger the table_html change handler + const tableHtmlChangeHandler = model.on.mock.calls.find( + (call) => call[0] === "change:table_html", + )[1]; + tableHtmlChangeHandler(); + + // Check that the table has two columns + const headers = el.querySelectorAll( + ".paginated-table-container .col-header-name", + ); + expect(headers).toHaveLength(2); + + // Check that the headers are an empty string (for the index) and "value" + expect(headers[0].textContent).toBe(""); + expect(headers[1].textContent).toBe("value"); + }); }); From c17fc200badd177656260b10fce2087c596aa787 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Mon, 5 Jan 2026 21:26:06 +0000 Subject: [PATCH 18/19] docs(tests): Add docstrings to anywidget tests --- tests/unit/display/test_anywidget.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py index a02843b889..7f0dadce03 100644 --- a/tests/unit/display/test_anywidget.py +++ b/tests/unit/display/test_anywidget.py @@ -81,6 +81,7 @@ def handler(signum, frame): def test_css_contains_dark_mode_media_query(): + """Test that the CSS for dark mode is loaded.""" from bigframes.display.anywidget import TableWidget mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) @@ -100,6 +101,7 @@ def test_css_contains_dark_mode_media_query(): @pytest.fixture def mock_df(): + """A mock DataFrame that can be used in multiple tests.""" df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) df.columns = ["col1", "col2"] df.dtypes = {"col1": "int64", "col2": "int64"} @@ -122,6 +124,7 @@ def mock_df(): def test_sorting_single_column(mock_df): + """Test that the widget can be sorted by a single column.""" from bigframes.display.anywidget import TableWidget with bigframes.option_context("display.repr_mode", "anywidget"): @@ -140,6 +143,7 @@ def test_sorting_single_column(mock_df): def test_sorting_multi_column(mock_df): + """Test that the widget can be sorted by multiple columns.""" from bigframes.display.anywidget import TableWidget with bigframes.option_context("display.repr_mode", "anywidget"): @@ -155,6 +159,7 @@ def test_sorting_multi_column(mock_df): def test_page_size_change_resets_sort(mock_df): + """Test that changing the page size resets the sorting.""" from bigframes.display.anywidget import TableWidget with bigframes.option_context("display.repr_mode", "anywidget"): From c3f916c9908258e2632f4434083f88d2761ce5c7 Mon Sep 17 00:00:00 2001 From: Shuowei Li Date: Tue, 6 Jan 2026 19:45:46 +0000 Subject: [PATCH 19/19] Fix: Hybrid theme detection for VS Code and Colab --- bigframes/display/table_widget.css | 13 +- bigframes/display/table_widget.js | 514 +++++++++++++-------------- tests/js/table_widget.test.js | 190 +++++----- tests/unit/display/test_anywidget.py | 9 +- 4 files changed, 367 insertions(+), 359 deletions(-) diff --git a/bigframes/display/table_widget.css b/bigframes/display/table_widget.css index 6d691e3455..b02caa004e 100644 --- a/bigframes/display/table_widget.css +++ b/bigframes/display/table_widget.css @@ -43,7 +43,11 @@ box-sizing: border-box; } -/* Dark Mode Overrides via Media Query */ +/* Dark Mode Overrides: + * 1. @media (prefers-color-scheme: dark) - System-wide dark mode + * 2. .bigframes-dark-mode - Explicit class for VSCode theme detection + * 3. html[theme="dark"], body[data-theme="dark"] - Colab/Pantheon manual override + */ @media (prefers-color-scheme: dark) { .bigframes-widget.bigframes-widget { --bf-bg: var(--vscode-editor-background, #202124); @@ -59,8 +63,9 @@ } } -/* Dark Mode Overrides via Explicit Class */ -.bigframes-widget.bigframes-dark-mode.bigframes-dark-mode { +.bigframes-widget.bigframes-dark-mode.bigframes-dark-mode, +html[theme='dark'] .bigframes-widget.bigframes-widget, +body[data-theme='dark'] .bigframes-widget.bigframes-widget { --bf-bg: var(--vscode-editor-background, #202124); --bf-border-color: #444; --bf-error-bg: #511; @@ -230,4 +235,4 @@ .bigframes-widget .debug-info { border-top: 1px solid var(--bf-border-color); -} \ No newline at end of file +} diff --git a/bigframes/display/table_widget.js b/bigframes/display/table_widget.js index 51d8a03405..40a027a8bc 100644 --- a/bigframes/display/table_widget.js +++ b/bigframes/display/table_widget.js @@ -15,19 +15,19 @@ */ const ModelProperty = { - ERROR_MESSAGE: "error_message", - ORDERABLE_COLUMNS: "orderable_columns", - PAGE: "page", - PAGE_SIZE: "page_size", - ROW_COUNT: "row_count", - SORT_CONTEXT: "sort_context", - TABLE_HTML: "table_html", + ERROR_MESSAGE: 'error_message', + ORDERABLE_COLUMNS: 'orderable_columns', + PAGE: 'page', + PAGE_SIZE: 'page_size', + ROW_COUNT: 'row_count', + SORT_CONTEXT: 'sort_context', + TABLE_HTML: 'table_html', }; const Event = { - CHANGE: "change", - CHANGE_TABLE_HTML: "change:table_html", - CLICK: "click", + CHANGE: 'change', + CHANGE_TABLE_HTML: 'change:table_html', + CLICK: 'click', }; /** @@ -35,253 +35,253 @@ const Event = { * @param {{ model: any, el: !HTMLElement }} props - The widget properties. */ function render({ model, el }) { - el.classList.add("bigframes-widget"); - - const errorContainer = document.createElement("div"); - errorContainer.classList.add("error-message"); - - const tableContainer = document.createElement("div"); - tableContainer.classList.add("table-container"); - const footer = document.createElement("footer"); - footer.classList.add("footer"); - - /** Detects theme and applies necessary style overrides. */ - function updateTheme() { - const body = document.body; - const isDark = - body.classList.contains("vscode-dark") || - body.classList.contains("theme-dark") || - body.dataset.theme === "dark" || - body.getAttribute("data-vscode-theme-kind") === "vscode-dark"; - - if (isDark) { - el.classList.add("bigframes-dark-mode"); - } else { - el.classList.remove("bigframes-dark-mode"); - } - } - - updateTheme(); - // Re-check after mount to ensure parent styling is applied. - setTimeout(updateTheme, 300); - - const observer = new MutationObserver(updateTheme); - observer.observe(document.body, { - attributes: true, - attributeFilter: ["class", "data-theme", "data-vscode-theme-kind"], - }); - - // Pagination controls - const paginationContainer = document.createElement("div"); - paginationContainer.classList.add("pagination"); - const prevPage = document.createElement("button"); - const pageIndicator = document.createElement("span"); - pageIndicator.classList.add("page-indicator"); - const nextPage = document.createElement("button"); - const rowCountLabel = document.createElement("span"); - rowCountLabel.classList.add("row-count"); - - // Page size controls - const pageSizeContainer = document.createElement("div"); - pageSizeContainer.classList.add("page-size"); - const pageSizeLabel = document.createElement("label"); - const pageSizeInput = document.createElement("select"); - - prevPage.textContent = "<"; - nextPage.textContent = ">"; - pageSizeLabel.textContent = "Page size:"; - - const pageSizes = [10, 25, 50, 100]; - for (const size of pageSizes) { - const option = document.createElement("option"); - option.value = size; - option.textContent = size; - if (size === model.get(ModelProperty.PAGE_SIZE)) { - option.selected = true; - } - pageSizeInput.appendChild(option); - } - - function updateButtonStates() { - const currentPage = model.get(ModelProperty.PAGE); - const pageSize = model.get(ModelProperty.PAGE_SIZE); - const rowCount = model.get(ModelProperty.ROW_COUNT); - - if (rowCount === null) { - rowCountLabel.textContent = "Total rows unknown"; - pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; - prevPage.disabled = currentPage === 0; - nextPage.disabled = false; - } else if (rowCount === 0) { - rowCountLabel.textContent = "0 total rows"; - pageIndicator.textContent = "Page 1 of 1"; - prevPage.disabled = true; - nextPage.disabled = true; - } else { - const totalPages = Math.ceil(rowCount / pageSize); - rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; - pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; - prevPage.disabled = currentPage === 0; - nextPage.disabled = currentPage >= totalPages - 1; - } - pageSizeInput.value = pageSize; - } - - function handlePageChange(direction) { - const currentPage = model.get(ModelProperty.PAGE); - model.set(ModelProperty.PAGE, currentPage + direction); - model.save_changes(); - } - - function handlePageSizeChange(newSize) { - model.set(ModelProperty.PAGE_SIZE, newSize); - model.set(ModelProperty.PAGE, 0); - model.save_changes(); - } - - function handleTableHTMLChange() { - tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML); - - const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS); - const currentSortContext = model.get(ModelProperty.SORT_CONTEXT) || []; - - const getSortIndex = (colName) => - currentSortContext.findIndex((item) => item.column === colName); - - const headers = tableContainer.querySelectorAll("th"); - headers.forEach((header) => { - const headerDiv = header.querySelector("div"); - const columnName = headerDiv.textContent.trim(); - - if (columnName && sortableColumns.includes(columnName)) { - header.style.cursor = "pointer"; - - const indicatorSpan = document.createElement("span"); - indicatorSpan.classList.add("sort-indicator"); - indicatorSpan.style.paddingLeft = "5px"; - - // Determine sort indicator and initial visibility - let indicator = "●"; // Default: unsorted (dot) - const sortIndex = getSortIndex(columnName); - - if (sortIndex !== -1) { - const isAscending = currentSortContext[sortIndex].ascending; - indicator = isAscending ? "▲" : "▼"; - indicatorSpan.style.visibility = "visible"; // Sorted arrows always visible - } else { - indicatorSpan.style.visibility = "hidden"; - } - indicatorSpan.textContent = indicator; - - const existingIndicator = headerDiv.querySelector(".sort-indicator"); - if (existingIndicator) { - headerDiv.removeChild(existingIndicator); - } - headerDiv.appendChild(indicatorSpan); - - header.addEventListener("mouseover", () => { - if (getSortIndex(columnName) === -1) { - indicatorSpan.style.visibility = "visible"; - } - }); - header.addEventListener("mouseout", () => { - if (getSortIndex(columnName) === -1) { - indicatorSpan.style.visibility = "hidden"; - } - }); - - // Add click handler for three-state toggle - header.addEventListener(Event.CLICK, (event) => { - const sortIndex = getSortIndex(columnName); - let newContext = [...currentSortContext]; - - if (event.shiftKey) { - if (sortIndex !== -1) { - // Already sorted. Toggle or Remove. - if (newContext[sortIndex].ascending) { - // Asc -> Desc - // Clone object to avoid mutation issues - newContext[sortIndex] = { - ...newContext[sortIndex], - ascending: false, - }; - } else { - // Desc -> Remove - newContext.splice(sortIndex, 1); - } - } else { - // Not sorted -> Append Asc - newContext.push({ column: columnName, ascending: true }); - } - } else { - // No shift key. Single column mode. - if (sortIndex !== -1 && newContext.length === 1) { - // Already only this column. Toggle or Remove. - if (newContext[sortIndex].ascending) { - newContext[sortIndex] = { - ...newContext[sortIndex], - ascending: false, - }; - } else { - newContext = []; - } - } else { - // Start fresh with this column - newContext = [{ column: columnName, ascending: true }]; - } - } - - model.set(ModelProperty.SORT_CONTEXT, newContext); - model.save_changes(); - }); - } - }); - - updateButtonStates(); - } - - function handleErrorMessageChange() { - const errorMsg = model.get(ModelProperty.ERROR_MESSAGE); - if (errorMsg) { - errorContainer.textContent = errorMsg; - errorContainer.style.display = "block"; - } else { - errorContainer.style.display = "none"; - } - } - - prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1)); - nextPage.addEventListener(Event.CLICK, () => handlePageChange(1)); - pageSizeInput.addEventListener(Event.CHANGE, (e) => { - const newSize = Number(e.target.value); - if (newSize) { - handlePageSizeChange(newSize); - } - }); - - model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); - model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); - model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange); - model.on(`change:_initial_load_complete`, (val) => { - if (val) updateButtonStates(); - }); - model.on(`change:${ModelProperty.PAGE}`, updateButtonStates); - - paginationContainer.appendChild(prevPage); - paginationContainer.appendChild(pageIndicator); - paginationContainer.appendChild(nextPage); - pageSizeContainer.appendChild(pageSizeLabel); - pageSizeContainer.appendChild(pageSizeInput); - footer.appendChild(rowCountLabel); - footer.appendChild(paginationContainer); - footer.appendChild(pageSizeContainer); - - el.appendChild(errorContainer); - el.appendChild(tableContainer); - el.appendChild(footer); - - handleTableHTMLChange(); - handleErrorMessageChange(); + el.classList.add('bigframes-widget'); + + const errorContainer = document.createElement('div'); + errorContainer.classList.add('error-message'); + + const tableContainer = document.createElement('div'); + tableContainer.classList.add('table-container'); + const footer = document.createElement('footer'); + footer.classList.add('footer'); + + /** Detects theme and applies necessary style overrides. */ + function updateTheme() { + const body = document.body; + const isDark = + body.classList.contains('vscode-dark') || + body.classList.contains('theme-dark') || + body.dataset.theme === 'dark' || + body.getAttribute('data-vscode-theme-kind') === 'vscode-dark'; + + if (isDark) { + el.classList.add('bigframes-dark-mode'); + } else { + el.classList.remove('bigframes-dark-mode'); + } + } + + updateTheme(); + // Re-check after mount to ensure parent styling is applied. + setTimeout(updateTheme, 300); + + const observer = new MutationObserver(updateTheme); + observer.observe(document.body, { + attributes: true, + attributeFilter: ['class', 'data-theme', 'data-vscode-theme-kind'], + }); + + // Pagination controls + const paginationContainer = document.createElement('div'); + paginationContainer.classList.add('pagination'); + const prevPage = document.createElement('button'); + const pageIndicator = document.createElement('span'); + pageIndicator.classList.add('page-indicator'); + const nextPage = document.createElement('button'); + const rowCountLabel = document.createElement('span'); + rowCountLabel.classList.add('row-count'); + + // Page size controls + const pageSizeContainer = document.createElement('div'); + pageSizeContainer.classList.add('page-size'); + const pageSizeLabel = document.createElement('label'); + const pageSizeInput = document.createElement('select'); + + prevPage.textContent = '<'; + nextPage.textContent = '>'; + pageSizeLabel.textContent = 'Page size:'; + + const pageSizes = [10, 25, 50, 100]; + for (const size of pageSizes) { + const option = document.createElement('option'); + option.value = size; + option.textContent = size; + if (size === model.get(ModelProperty.PAGE_SIZE)) { + option.selected = true; + } + pageSizeInput.appendChild(option); + } + + function updateButtonStates() { + const currentPage = model.get(ModelProperty.PAGE); + const pageSize = model.get(ModelProperty.PAGE_SIZE); + const rowCount = model.get(ModelProperty.ROW_COUNT); + + if (rowCount === null) { + rowCountLabel.textContent = 'Total rows unknown'; + pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of many`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = false; + } else if (rowCount === 0) { + rowCountLabel.textContent = '0 total rows'; + pageIndicator.textContent = 'Page 1 of 1'; + prevPage.disabled = true; + nextPage.disabled = true; + } else { + const totalPages = Math.ceil(rowCount / pageSize); + rowCountLabel.textContent = `${rowCount.toLocaleString()} total rows`; + pageIndicator.textContent = `Page ${(currentPage + 1).toLocaleString()} of ${totalPages.toLocaleString()}`; + prevPage.disabled = currentPage === 0; + nextPage.disabled = currentPage >= totalPages - 1; + } + pageSizeInput.value = pageSize; + } + + function handlePageChange(direction) { + const currentPage = model.get(ModelProperty.PAGE); + model.set(ModelProperty.PAGE, currentPage + direction); + model.save_changes(); + } + + function handlePageSizeChange(newSize) { + model.set(ModelProperty.PAGE_SIZE, newSize); + model.set(ModelProperty.PAGE, 0); + model.save_changes(); + } + + function handleTableHTMLChange() { + tableContainer.innerHTML = model.get(ModelProperty.TABLE_HTML); + + const sortableColumns = model.get(ModelProperty.ORDERABLE_COLUMNS); + const currentSortContext = model.get(ModelProperty.SORT_CONTEXT) || []; + + const getSortIndex = (colName) => + currentSortContext.findIndex((item) => item.column === colName); + + const headers = tableContainer.querySelectorAll('th'); + headers.forEach((header) => { + const headerDiv = header.querySelector('div'); + const columnName = headerDiv.textContent.trim(); + + if (columnName && sortableColumns.includes(columnName)) { + header.style.cursor = 'pointer'; + + const indicatorSpan = document.createElement('span'); + indicatorSpan.classList.add('sort-indicator'); + indicatorSpan.style.paddingLeft = '5px'; + + // Determine sort indicator and initial visibility + let indicator = '●'; // Default: unsorted (dot) + const sortIndex = getSortIndex(columnName); + + if (sortIndex !== -1) { + const isAscending = currentSortContext[sortIndex].ascending; + indicator = isAscending ? '▲' : '▼'; + indicatorSpan.style.visibility = 'visible'; // Sorted arrows always visible + } else { + indicatorSpan.style.visibility = 'hidden'; + } + indicatorSpan.textContent = indicator; + + const existingIndicator = headerDiv.querySelector('.sort-indicator'); + if (existingIndicator) { + headerDiv.removeChild(existingIndicator); + } + headerDiv.appendChild(indicatorSpan); + + header.addEventListener('mouseover', () => { + if (getSortIndex(columnName) === -1) { + indicatorSpan.style.visibility = 'visible'; + } + }); + header.addEventListener('mouseout', () => { + if (getSortIndex(columnName) === -1) { + indicatorSpan.style.visibility = 'hidden'; + } + }); + + // Add click handler for three-state toggle + header.addEventListener(Event.CLICK, (event) => { + const sortIndex = getSortIndex(columnName); + let newContext = [...currentSortContext]; + + if (event.shiftKey) { + if (sortIndex !== -1) { + // Already sorted. Toggle or Remove. + if (newContext[sortIndex].ascending) { + // Asc -> Desc + // Clone object to avoid mutation issues + newContext[sortIndex] = { + ...newContext[sortIndex], + ascending: false, + }; + } else { + // Desc -> Remove + newContext.splice(sortIndex, 1); + } + } else { + // Not sorted -> Append Asc + newContext.push({ column: columnName, ascending: true }); + } + } else { + // No shift key. Single column mode. + if (sortIndex !== -1 && newContext.length === 1) { + // Already only this column. Toggle or Remove. + if (newContext[sortIndex].ascending) { + newContext[sortIndex] = { + ...newContext[sortIndex], + ascending: false, + }; + } else { + newContext = []; + } + } else { + // Start fresh with this column + newContext = [{ column: columnName, ascending: true }]; + } + } + + model.set(ModelProperty.SORT_CONTEXT, newContext); + model.save_changes(); + }); + } + }); + + updateButtonStates(); + } + + function handleErrorMessageChange() { + const errorMsg = model.get(ModelProperty.ERROR_MESSAGE); + if (errorMsg) { + errorContainer.textContent = errorMsg; + errorContainer.style.display = 'block'; + } else { + errorContainer.style.display = 'none'; + } + } + + prevPage.addEventListener(Event.CLICK, () => handlePageChange(-1)); + nextPage.addEventListener(Event.CLICK, () => handlePageChange(1)); + pageSizeInput.addEventListener(Event.CHANGE, (e) => { + const newSize = Number(e.target.value); + if (newSize) { + handlePageSizeChange(newSize); + } + }); + + model.on(Event.CHANGE_TABLE_HTML, handleTableHTMLChange); + model.on(`change:${ModelProperty.ROW_COUNT}`, updateButtonStates); + model.on(`change:${ModelProperty.ERROR_MESSAGE}`, handleErrorMessageChange); + model.on(`change:_initial_load_complete`, (val) => { + if (val) updateButtonStates(); + }); + model.on(`change:${ModelProperty.PAGE}`, updateButtonStates); + + paginationContainer.appendChild(prevPage); + paginationContainer.appendChild(pageIndicator); + paginationContainer.appendChild(nextPage); + pageSizeContainer.appendChild(pageSizeLabel); + pageSizeContainer.appendChild(pageSizeInput); + footer.appendChild(rowCountLabel); + footer.appendChild(paginationContainer); + footer.appendChild(pageSizeContainer); + + el.appendChild(errorContainer); + el.appendChild(tableContainer); + el.appendChild(footer); + + handleTableHTMLChange(); + handleErrorMessageChange(); } export default { render }; diff --git a/tests/js/table_widget.test.js b/tests/js/table_widget.test.js index b42ca65518..5843694617 100644 --- a/tests/js/table_widget.test.js +++ b/tests/js/table_widget.test.js @@ -14,20 +14,20 @@ * limitations under the License. */ -import { jest } from "@jest/globals"; +import { jest } from '@jest/globals'; -describe("TableWidget", () => { +describe('TableWidget', () => { let model; let el; let render; beforeEach(async () => { jest.resetModules(); - document.body.innerHTML = "
"; - el = document.body.querySelector("div"); + document.body.innerHTML = '
'; + el = document.body.querySelector('div'); const tableWidget = ( - await import("../../bigframes/display/table_widget.js") + await import('../../bigframes/display/table_widget.js') ).default; render = tableWidget.render; @@ -39,27 +39,27 @@ describe("TableWidget", () => { }; }); - it("should have a render function", () => { + it('should have a render function', () => { expect(render).toBeDefined(); }); - describe("render", () => { - it("should create the basic structure", () => { + describe('render', () => { + it('should create the basic structure', () => { // Mock the initial state model.get.mockImplementation((property) => { - if (property === "table_html") { - return ""; + if (property === 'table_html') { + return ''; } - if (property === "row_count") { + if (property === 'row_count') { return 100; } - if (property === "error_message") { + if (property === 'error_message') { return null; } - if (property === "page_size") { + if (property === 'page_size') { return 10; } - if (property === "page") { + if (property === 'page') { return 0; } return null; @@ -67,22 +67,22 @@ describe("TableWidget", () => { render({ model, el }); - expect(el.classList.contains("bigframes-widget")).toBe(true); - expect(el.querySelector(".error-message")).not.toBeNull(); - expect(el.querySelector("div")).not.toBeNull(); - expect(el.querySelector("div:nth-child(3)")).not.toBeNull(); + expect(el.classList.contains('bigframes-widget')).toBe(true); + expect(el.querySelector('.error-message')).not.toBeNull(); + expect(el.querySelector('div')).not.toBeNull(); + expect(el.querySelector('div:nth-child(3)')).not.toBeNull(); }); - it("should sort when a sortable column is clicked", () => { + it('should sort when a sortable column is clicked', () => { // Mock the initial state model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
"; + if (property === 'table_html') { + return '
col1
'; } - if (property === "orderable_columns") { - return ["col1"]; + if (property === 'orderable_columns') { + return ['col1']; } - if (property === "sort_context") { + if (property === 'sort_context') { return []; } return null; @@ -92,30 +92,30 @@ describe("TableWidget", () => { // Manually trigger the table_html change handler const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", + (call) => call[0] === 'change:table_html', )[1]; tableHtmlChangeHandler(); - const header = el.querySelector("th"); + const header = el.querySelector('th'); header.click(); - expect(model.set).toHaveBeenCalledWith("sort_context", [ - { column: "col1", ascending: true }, + expect(model.set).toHaveBeenCalledWith('sort_context', [ + { column: 'col1', ascending: true }, ]); expect(model.save_changes).toHaveBeenCalled(); }); - it("should reverse sort direction when a sorted column is clicked", () => { + it('should reverse sort direction when a sorted column is clicked', () => { // Mock the initial state model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
"; + if (property === 'table_html') { + return '
col1
'; } - if (property === "orderable_columns") { - return ["col1"]; + if (property === 'orderable_columns') { + return ['col1']; } - if (property === "sort_context") { - return [{ column: "col1", ascending: true }]; + if (property === 'sort_context') { + return [{ column: 'col1', ascending: true }]; } return null; }); @@ -124,30 +124,30 @@ describe("TableWidget", () => { // Manually trigger the table_html change handler const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", + (call) => call[0] === 'change:table_html', )[1]; tableHtmlChangeHandler(); - const header = el.querySelector("th"); + const header = el.querySelector('th'); header.click(); - expect(model.set).toHaveBeenCalledWith("sort_context", [ - { column: "col1", ascending: false }, + expect(model.set).toHaveBeenCalledWith('sort_context', [ + { column: 'col1', ascending: false }, ]); expect(model.save_changes).toHaveBeenCalled(); }); - it("should clear sort when a descending sorted column is clicked", () => { + it('should clear sort when a descending sorted column is clicked', () => { // Mock the initial state model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
"; + if (property === 'table_html') { + return '
col1
'; } - if (property === "orderable_columns") { - return ["col1"]; + if (property === 'orderable_columns') { + return ['col1']; } - if (property === "sort_context") { - return [{ column: "col1", ascending: false }]; + if (property === 'sort_context') { + return [{ column: 'col1', ascending: false }]; } return null; }); @@ -156,28 +156,28 @@ describe("TableWidget", () => { // Manually trigger the table_html change handler const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", + (call) => call[0] === 'change:table_html', )[1]; tableHtmlChangeHandler(); - const header = el.querySelector("th"); + const header = el.querySelector('th'); header.click(); - expect(model.set).toHaveBeenCalledWith("sort_context", []); + expect(model.set).toHaveBeenCalledWith('sort_context', []); expect(model.save_changes).toHaveBeenCalled(); }); - it("should display the correct sort indicator", () => { + it('should display the correct sort indicator', () => { // Mock the initial state model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
col2
"; + if (property === 'table_html') { + return '
col1
col2
'; } - if (property === "orderable_columns") { - return ["col1", "col2"]; + if (property === 'orderable_columns') { + return ['col1', 'col2']; } - if (property === "sort_context") { - return [{ column: "col1", ascending: true }]; + if (property === 'sort_context') { + return [{ column: 'col1', ascending: true }]; } return null; }); @@ -186,29 +186,29 @@ describe("TableWidget", () => { // Manually trigger the table_html change handler const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", + (call) => call[0] === 'change:table_html', )[1]; tableHtmlChangeHandler(); - const headers = el.querySelectorAll("th"); - const indicator1 = headers[0].querySelector(".sort-indicator"); - const indicator2 = headers[1].querySelector(".sort-indicator"); + const headers = el.querySelectorAll('th'); + const indicator1 = headers[0].querySelector('.sort-indicator'); + const indicator2 = headers[1].querySelector('.sort-indicator'); - expect(indicator1.textContent).toBe("▲"); - expect(indicator2.textContent).toBe("●"); + expect(indicator1.textContent).toBe('▲'); + expect(indicator2.textContent).toBe('●'); }); - it("should add a column to sort when Shift+Click is used", () => { + it('should add a column to sort when Shift+Click is used', () => { // Mock the initial state: already sorted by col1 asc model.get.mockImplementation((property) => { - if (property === "table_html") { - return "
col1
col2
"; + if (property === 'table_html') { + return '
col1
col2
'; } - if (property === "orderable_columns") { - return ["col1", "col2"]; + if (property === 'orderable_columns') { + return ['col1', 'col2']; } - if (property === "sort_context") { - return [{ column: "col1", ascending: true }]; + if (property === 'sort_context') { + return [{ column: 'col1', ascending: true }]; } return null; }); @@ -217,47 +217,47 @@ describe("TableWidget", () => { // Manually trigger the table_html change handler const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", + (call) => call[0] === 'change:table_html', )[1]; tableHtmlChangeHandler(); - const headers = el.querySelectorAll("th"); + const headers = el.querySelectorAll('th'); const header2 = headers[1]; // col2 // Simulate Shift+Click - const clickEvent = new MouseEvent("click", { + const clickEvent = new MouseEvent('click', { bubbles: true, cancelable: true, shiftKey: true, }); header2.dispatchEvent(clickEvent); - expect(model.set).toHaveBeenCalledWith("sort_context", [ - { column: "col1", ascending: true }, - { column: "col2", ascending: true }, + expect(model.set).toHaveBeenCalledWith('sort_context', [ + { column: 'col1', ascending: true }, + { column: 'col2', ascending: true }, ]); expect(model.save_changes).toHaveBeenCalled(); }); }); - describe("Theme detection", () => { + describe('Theme detection', () => { beforeEach(() => { jest.useFakeTimers(); // Mock the initial state for theme detection tests model.get.mockImplementation((property) => { - if (property === "table_html") { - return ""; + if (property === 'table_html') { + return ''; } - if (property === "row_count") { + if (property === 'row_count') { return 100; } - if (property === "error_message") { + if (property === 'error_message') { return null; } - if (property === "page_size") { + if (property === 'page_size') { return 10; } - if (property === "page") { + if (property === 'page') { return 0; } return null; @@ -266,27 +266,27 @@ describe("TableWidget", () => { afterEach(() => { jest.useRealTimers(); - document.body.classList.remove("vscode-dark"); + document.body.classList.remove('vscode-dark'); }); - it("should add bigframes-dark-mode class in dark mode", () => { - document.body.classList.add("vscode-dark"); + it('should add bigframes-dark-mode class in dark mode', () => { + document.body.classList.add('vscode-dark'); render({ model, el }); jest.runAllTimers(); - expect(el.classList.contains("bigframes-dark-mode")).toBe(true); + expect(el.classList.contains('bigframes-dark-mode')).toBe(true); }); - it("should not add bigframes-dark-mode class in light mode", () => { + it('should not add bigframes-dark-mode class in light mode', () => { render({ model, el }); jest.runAllTimers(); - expect(el.classList.contains("bigframes-dark-mode")).toBe(false); + expect(el.classList.contains('bigframes-dark-mode')).toBe(false); }); }); - it("should render the series as a table with an index and one value column", () => { + it('should render the series as a table with an index and one value column', () => { // Mock the initial state model.get.mockImplementation((property) => { - if (property === "table_html") { + if (property === 'table_html') { return `
@@ -311,7 +311,7 @@ describe("TableWidget", () => {
`; } - if (property === "orderable_columns") { + if (property === 'orderable_columns') { return []; } return null; @@ -321,18 +321,18 @@ describe("TableWidget", () => { // Manually trigger the table_html change handler const tableHtmlChangeHandler = model.on.mock.calls.find( - (call) => call[0] === "change:table_html", + (call) => call[0] === 'change:table_html', )[1]; tableHtmlChangeHandler(); // Check that the table has two columns const headers = el.querySelectorAll( - ".paginated-table-container .col-header-name", + '.paginated-table-container .col-header-name', ); expect(headers).toHaveLength(2); // Check that the headers are an empty string (for the index) and "value" - expect(headers[0].textContent).toBe(""); - expect(headers[1].textContent).toBe("value"); + expect(headers[0].textContent).toBe(''); + expect(headers[1].textContent).toBe('value'); }); }); diff --git a/tests/unit/display/test_anywidget.py b/tests/unit/display/test_anywidget.py index 7f0dadce03..252ba8100e 100644 --- a/tests/unit/display/test_anywidget.py +++ b/tests/unit/display/test_anywidget.py @@ -80,8 +80,8 @@ def handler(signum, frame): signal.alarm(0) -def test_css_contains_dark_mode_media_query(): - """Test that the CSS for dark mode is loaded.""" +def test_css_contains_dark_mode_selectors(): + """Test that the CSS for dark mode is loaded with all required selectors.""" from bigframes.display.anywidget import TableWidget mock_df = mock.create_autospec(bigframes.dataframe.DataFrame, instance=True) @@ -96,7 +96,10 @@ def test_css_contains_dark_mode_media_query(): with mock.patch.object(TableWidget, "_initial_load"): widget = TableWidget(mock_df) - assert "@media (prefers-color-scheme: dark)" in widget._css + css = widget._css + assert "@media (prefers-color-scheme: dark)" in css + assert 'html[theme="dark"]' in css + assert 'body[data-theme="dark"]' in css @pytest.fixture