From 955a23afb76b745f12654bb38b714fac7c97e98c Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 12:43:26 +0200 Subject: [PATCH 01/21] Pin the header and title bar to the viewport while a wide table scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit On a wide index table the whole page scrolls horizontally. Pin #header and #title_bar to the viewport's left edge and cap them to the viewport width, so the logo, nav, page title AND action buttons all stay visible while the table scrolls underneath (no `top`, so they still scroll vertically). #titlebar_right resets to static — the bar does the pinning now, not the buttons. Width is min(100vw - var(--scrollbar-width), 100%): plain 100vw includes the vertical scrollbar gutter and shifts the left-pinned bars at the far-right scroll position. The optional wigu/scrollbar_width.js sets --scrollbar-width; without it the var falls back to 0 (plain 100vw). Consuming apps each solved this with their own CSS (scroll_buttons_init.scss, title_bar overrides) and JS — this owns it for all of them. --- .../javascripts/wigu/scrollbar_width.js | 23 +++++++++ .../stylesheets/wigu/active_admin_theme.scss | 45 +++++++++++++++++- img/titlebar-scroll-after.png | Bin 0 -> 30549 bytes img/titlebar-scroll-before.png | Bin 0 -> 26491 bytes test/dummy/app/admin/wide_table.rb | 31 ++++++++++++ .../app/assets/javascripts/active_admin.js | 1 + 6 files changed, 98 insertions(+), 2 deletions(-) create mode 100644 app/assets/javascripts/wigu/scrollbar_width.js create mode 100644 img/titlebar-scroll-after.png create mode 100644 img/titlebar-scroll-before.png create mode 100644 test/dummy/app/admin/wide_table.rb diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js new file mode 100644 index 0000000..2aa022b --- /dev/null +++ b/app/assets/javascripts/wigu/scrollbar_width.js @@ -0,0 +1,23 @@ +// Expose the vertical scrollbar width as a CSS custom property +// (--scrollbar-width) on . The sticky #header and #title_bar are pinned to +// the viewport's left edge and sized with 100vw, but 100vw INCLUDES the vertical +// scrollbar gutter — so when a page has a vertical scrollbar the bars are ~15px +// wider than the visible viewport and, clamped inside their full-content-width +// containing block, get pushed left by that amount at the far-right horizontal +// scroll position. Subtracting this variable (0 when there is no scrollbar) sizes +// them to the visible viewport instead, so they stay put. Optional: the CSS falls +// back to `var(--scrollbar-width, 0px)` = plain 100vw when this script is absent. +(function () { + function update() { + var w = window.innerWidth - document.documentElement.clientWidth; + document.documentElement.style.setProperty("--scrollbar-width", (w > 0 ? w : 0) + "px"); + } + window.addEventListener("resize", update); + document.addEventListener("DOMContentLoaded", function () { + update(); + // The presence of a vertical scrollbar can change after load (content grows/ + // shrinks) without firing resize — watch the body's box too. + if (window.ResizeObserver) { new ResizeObserver(update).observe(document.body); } + }); + update(); +})(); diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index e5fb5b9..005559c 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -463,7 +463,12 @@ body.active_admin { // wrapped menu. position: sticky; left: 0; - width: 100vw; + // Viewport width minus the vertical scrollbar (JS-set --scrollbar-width var, + // 0 when absent — load wigu/scrollbar_width), capped to the content width. + // Plain 100vw includes the scrollbar gutter, which shifts this left-pinned + // bar at the far-right scroll position; subtracting it keeps it put. unquote + // so Sass emits the min() literally (it can't mix % + vw). + width: unquote("min(calc(100vw - var(--scrollbar-width, 0px)), 100%)"); box-sizing: border-box; h1.site_title { color: $skinMainSecondColor; @@ -657,11 +662,37 @@ body.active_admin { background-color: $skinTitleBarColor; border: none; border-bottom: $skinTitleBarBorderWidth solid $skinTitleBarBorderColor; + // Pin the whole bar to the viewport like #header, so the title AND the + // action buttons stay visible while a wide index table scrolls the page + // sideways. width = the viewport (minus the vertical scrollbar via the + // JS-set --scrollbar-width var, 0 when absent — load wigu/scrollbar_width to + // populate it), capped to the content width so 100vw's scrollbar allowance + // can't add a stray horizontal scrollbar. No `top`, so it still scrolls + // vertically. unquote so Sass emits the min() literally (it can't mix % + vw). + position: sticky; + left: 0; + width: unquote("min(calc(100vw - var(--scrollbar-width, 0px)), 100%)"); + box-sizing: border-box; + // Lay the title (left) and action items (right) on one compact row. Without + // this, resetting #titlebar_right to static (below) stacks it under the + // title and the bar grows tall. Fixed height so pages with a breadcrumb + // (detail pages) match plain index pages. Drop the base clearfix — in a flex + // container it becomes a stray third item that space-between strands right. + display: flex; + align-items: center; + justify-content: space-between; + height: 37px; + &::after { display: none; } #titlebar_left { + display: flex; + align-items: center; + gap: 6px; + height: auto; span.breadcrumb { font-size: 12px; text-shadow: none; - margin-bottom: 8px; + float: none; + margin-bottom: 0; a, span { color: lighten( $skinMainFirstColor, 30% )!important; ; } @@ -672,6 +703,16 @@ body.active_admin { } } #titlebar_right { + // Horizontal pinning is handled by #title_bar (sticky + viewport width), + // so the action items just sit at the right of the bar. Reset any + // consumer `position: absolute/sticky` (each app used to pin the buttons + // itself via a local scroll_buttons_init.scss / title_bar override). + position: static; + right: auto; + top: auto; + // Shrink to the action-items height (some apps/base leave it ~50px, taller + // than the 37px bar, so centering pushed it over the top edge). + height: auto; // `> a` and `> .dropdown_menu > a`: an action item may hold a dropdown, and // a descendant selector here paints its menu entries with the button fill // too — with !important, so the entry rules below cannot undo it. diff --git a/img/titlebar-scroll-after.png b/img/titlebar-scroll-after.png new file mode 100644 index 0000000000000000000000000000000000000000..00c4c6b38435a148719e46534eabb22e5a3c74e4 GIT binary patch literal 30549 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 literal 0 HcmV?d00001 diff --git a/img/titlebar-scroll-before.png b/img/titlebar-scroll-before.png new file mode 100644 index 0000000000000000000000000000000000000000..dd2c26405a3982f3f82c9c75d537db442cc7030b GIT binary patch literal 26491 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 JMbf6Qe*=EJ=J5ak literal 0 HcmV?d00001 diff --git a/test/dummy/app/admin/wide_table.rb b/test/dummy/app/admin/wide_table.rb new file mode 100644 index 0000000..a1c243a --- /dev/null +++ b/test/dummy/app/admin/wide_table.rb @@ -0,0 +1,31 @@ +# Demo page with a table wider than the viewport, so the page scrolls +# horizontally — used to show the header + title bar (title on the left, the +# action button on the right) staying pinned while the table scrolls under them. +ActiveAdmin.register_page "Wide table" do + menu priority: 4 + + action_item(:new_record) { link_to "New record", "#" } + + WIDE_COLS = (1..12).map { |i| "Column #{i}" }.freeze + WIDE_ROWS = (1..5).map { |r| (1..12).map { |c| "row #{r} / value #{c}" } }.freeze + + content do + # nowrap + a min cell width so the 12 columns push the table past the + # viewport and the page scrolls sideways (what the sticky bars are for). + text_node <<~HTML.html_safe + + HTML + panel "A table wider than the viewport" do + head = "" + WIDE_COLS.map { |c| "#{c}" }.join + "" + body = WIDE_ROWS.map do |row| + "" + row.map { |cell| "#{ERB::Util.html_escape(cell)}" }.join + "" + end.join + text_node( + (%() + head + "" + body + "
") + .html_safe + ) + end + end +end diff --git a/test/dummy/app/assets/javascripts/active_admin.js b/test/dummy/app/assets/javascripts/active_admin.js index ddd00b0..10de081 100644 --- a/test/dummy/app/assets/javascripts/active_admin.js +++ b/test/dummy/app/assets/javascripts/active_admin.js @@ -3,3 +3,4 @@ //= require wigu/server_clock //= require wigu/theme_toggle //= require wigu/table_crisscross +//= require wigu/scrollbar_width From fa32ac5d6062fa5c8f8024619a426e5b6afb78cf Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 16:03:31 +0200 Subject: [PATCH 02/21] Use max-width instead of min() so the sticky bar compiles under sassc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The viewport-width cap on #header and #title_bar was written as unquote("min(calc(100vw - var(--scrollbar-width, 0px)), 100%)"). dart-sass emits that fine, but sprockets consumers (RouteServer, pbx) compile the theme through sassc/libsass, whose Sass min() function tries to evaluate its arguments numerically and rejects calc() with "is not a number for `min'" — failing the whole asset build. Express the same cap with a plain CSS max-width: 100% and keep the width as a bare calc(), which libsass passes through untouched. Behaviour is identical in every browser; the npm (dart-sass) consumers are unaffected since src/ is generated from this file by the prepare script. --- .../stylesheets/wigu/active_admin_theme.scss | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 005559c..73e912f 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -466,9 +466,11 @@ body.active_admin { // Viewport width minus the vertical scrollbar (JS-set --scrollbar-width var, // 0 when absent — load wigu/scrollbar_width), capped to the content width. // Plain 100vw includes the scrollbar gutter, which shifts this left-pinned - // bar at the far-right scroll position; subtracting it keeps it put. unquote - // so Sass emits the min() literally (it can't mix % + vw). - width: unquote("min(calc(100vw - var(--scrollbar-width, 0px)), 100%)"); + // bar at the far-right scroll position; subtracting it keeps it put. The cap + // is a plain max-width, not min(…, 100%): sassc/libsass (sprockets consumers) + // evaluates the Sass min() function and rejects calc() as a non-number. + width: calc(100vw - var(--scrollbar-width, 0px)); + max-width: 100%; box-sizing: border-box; h1.site_title { color: $skinMainSecondColor; @@ -666,12 +668,14 @@ body.active_admin { // action buttons stay visible while a wide index table scrolls the page // sideways. width = the viewport (minus the vertical scrollbar via the // JS-set --scrollbar-width var, 0 when absent — load wigu/scrollbar_width to - // populate it), capped to the content width so 100vw's scrollbar allowance - // can't add a stray horizontal scrollbar. No `top`, so it still scrolls - // vertically. unquote so Sass emits the min() literally (it can't mix % + vw). + // populate it), capped to the content width with a plain max-width so 100vw's + // scrollbar allowance can't add a stray horizontal scrollbar. No `top`, so it + // still scrolls vertically. The cap is max-width, not min(…, 100%): sassc/ + // libsass (sprockets consumers) evaluates the Sass min() and rejects calc(). position: sticky; left: 0; - width: unquote("min(calc(100vw - var(--scrollbar-width, 0px)), 100%)"); + width: calc(100vw - var(--scrollbar-width, 0px)); + max-width: 100%; box-sizing: border-box; // Lay the title (left) and action items (right) on one compact row. Without // this, resetting #titlebar_right to static (below) stacks it under the From 34a9a9bbefaa6024b7d7e11e3ac4e4f808147fa5 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 16:37:51 +0200 Subject: [PATCH 03/21] Sticky index-table headers via CSS position:sticky Keep an index table's column headers visible while scrolling it vertically, replacing the per-app implementations (RouteServer shipped a 280-line jQuery stickyTableHeaders plugin keyed off hardcoded table ids; api-3 had its own CSS). Scoped to .paginated_collection so it only applies to real ActiveAdmin index listings, not custom report/dashboard tables. A listing opts out with .skip-sticky-headers; a non-index table_for opts in with .with-sticky-headers. top: -1px hides the 1px header border seam. The header and title bar pin only horizontally (no top), so they scroll away vertically and the thead pins to the viewport top with no overlap. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 73e912f..dd032a5 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -1352,6 +1352,19 @@ table.index_table thead th a { color: var(--aa-table-header-text) !important; } +// Sticky table headers: keep the column labels visible while a long index table +// scrolls vertically. The header/title bar pin only horizontally (no `top`), so +// they scroll away on vertical scroll and this thead pins to the viewport top — +// no overlap. Scoped to .paginated_collection so it only hits real AA index +// listings, not custom report/dashboard tables. Opt a listing out with +// .skip-sticky-headers; opt a non-index table_for in with .with-sticky-headers. +.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th, +table.with-sticky-headers thead tr th { + position: sticky; + top: -1px; + z-index: 98; +} + // Form section header: a interrupts the fieldset's top border, so the // header bar has no border around it (unlike a panel header). Float the legend // so it flows as a normal block and the fieldset border wraps around it. From 8e295d3604d97eabd245a91309c60f3a147f7d8a Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 17:10:25 +0200 Subject: [PATCH 04/21] Extract the viewport-pinned-bar mixin shared by #header and #title_bar #header and #title_bar carried the same sticky + 100vw-minus-scrollbar + max-width pin, comment and all, twice. Pull it into a @mixin viewport-pinned-bar so the width mechanics and their rationale live in one place; @include it from both. The mixin inlines, so the compiled CSS is unchanged. Also drops a stray empty declaration (double semicolon) in the breadcrumb colour. --- .../stylesheets/wigu/active_admin_theme.scss | 57 +++++++++---------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index dd032a5..4932b8b 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -380,6 +380,23 @@ $logout-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' html { background: var(--aa-page-bg); } + +// Pin a bar (#header, #title_bar) to the viewport's left edge at the visible +// viewport width while a wide index table scrolls the whole page sideways +// (#wrapper is display:table, min-width:100%). width = 100vw minus the vertical +// scrollbar — the JS-set --scrollbar-width var (0 when wigu/scrollbar_width is +// absent, and then max-width:100% keeps 100vw's scrollbar allowance from adding a +// stray horizontal scrollbar). It's a plain max-width, not min(…, 100%): +// sassc/libsass evaluates the Sass min() function and rejects calc() as a +// non-number. No `top`, so the bar still scrolls vertically. +@mixin viewport-pinned-bar { + position: sticky; + left: 0; + width: calc(100vw - var(--scrollbar-width, 0px)); + max-width: 100%; + box-sizing: border-box; +} + body.active_admin { background: var(--aa-page-bg); } @@ -455,23 +472,11 @@ body.active_admin { text-shadow: none; background-image: none; - // A wide index table scrolls the WHOLE page sideways (#wrapper is - // display:table, min-width:100%). Keep the header pinned on screen and at the - // viewport width while that happens, so it doesn't stretch to the table's - // width (which spread the menu out under the utility nav and overlapped it). - // The header keeps ActiveAdmin's natural layout, so it still grows to fit a - // wrapped menu. - position: sticky; - left: 0; - // Viewport width minus the vertical scrollbar (JS-set --scrollbar-width var, - // 0 when absent — load wigu/scrollbar_width), capped to the content width. - // Plain 100vw includes the scrollbar gutter, which shifts this left-pinned - // bar at the far-right scroll position; subtracting it keeps it put. The cap - // is a plain max-width, not min(…, 100%): sassc/libsass (sprockets consumers) - // evaluates the Sass min() function and rejects calc() as a non-number. - width: calc(100vw - var(--scrollbar-width, 0px)); - max-width: 100%; - box-sizing: border-box; + // A wide index table scrolls the WHOLE page sideways. Pin the header on + // screen at the viewport width so it doesn't stretch to the table's width + // (which spread the menu out under the utility nav and overlapped it); it + // still grows to fit a wrapped menu, ActiveAdmin's natural layout. + @include viewport-pinned-bar; h1.site_title { color: $skinMainSecondColor; font-size: 16px; @@ -664,19 +669,9 @@ body.active_admin { background-color: $skinTitleBarColor; border: none; border-bottom: $skinTitleBarBorderWidth solid $skinTitleBarBorderColor; - // Pin the whole bar to the viewport like #header, so the title AND the - // action buttons stay visible while a wide index table scrolls the page - // sideways. width = the viewport (minus the vertical scrollbar via the - // JS-set --scrollbar-width var, 0 when absent — load wigu/scrollbar_width to - // populate it), capped to the content width with a plain max-width so 100vw's - // scrollbar allowance can't add a stray horizontal scrollbar. No `top`, so it - // still scrolls vertically. The cap is max-width, not min(…, 100%): sassc/ - // libsass (sprockets consumers) evaluates the Sass min() and rejects calc(). - position: sticky; - left: 0; - width: calc(100vw - var(--scrollbar-width, 0px)); - max-width: 100%; - box-sizing: border-box; + // Pin the whole bar to the viewport like #header, so the title AND the action + // buttons stay visible while a wide index table scrolls the page sideways. + @include viewport-pinned-bar; // Lay the title (left) and action items (right) on one compact row. Without // this, resetting #titlebar_right to static (below) stacks it under the // title and the bar grows tall. Fixed height so pages with a breadcrumb @@ -698,7 +693,7 @@ body.active_admin { float: none; margin-bottom: 0; a, span { - color: lighten( $skinMainFirstColor, 30% )!important; ; + color: lighten( $skinMainFirstColor, 30% )!important; } } h2 { From 7c3500a4f29e69f99f5e4aef7b377429dc72cbe3 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 18:41:35 +0200 Subject: [PATCH 05/21] Own the title-bar h2 size, action-item spacing and delete-link in the theme So consumer apps don't each re-style the title bar: - #title_bar h2: font-size 16px + margin:0 + nowrap. AA's default (~31px) towered over the breadcrumb and every app shrank it locally (RS had lost its override and showed the huge default). - #titlebar_right: 10px gap between multiple action items (was flush). - delete links: also match a.delete-link (hyphen), not just a.delete_link. Lets RouteServer/api-3 drop their local title_bar/titlebar overrides entirely. --- .../stylesheets/wigu/active_admin_theme.scss | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 4932b8b..c21f96f 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -696,9 +696,16 @@ body.active_admin { color: lighten( $skinMainFirstColor, 30% )!important; } } + // Own a sensible title size so consumer apps don't each shrink AA's large + // default (which towered over the breadcrumb). margin:0 + the flex parent + // centre it on the breadcrumb's line; nowrap keeps a long record title + // (show pages) from wrapping the bar. h2 { color: #ffffff; text-shadow: none; + font-size: 16px; + margin: 0; + white-space: nowrap; } } #titlebar_right { @@ -712,6 +719,9 @@ body.active_admin { // Shrink to the action-items height (some apps/base leave it ~50px, taller // than the 37px bar, so centering pushed it over the top edge). height: auto; + // Space multiple action items apart (e.g. a Menu dropdown next to a New + // button); the base leaves them flush on this side. + .action_items > .action_item:not(:last-child):not(:empty) { margin-right: 10px; } // `> a` and `> .dropdown_menu > a`: an action item may hold a dropdown, and // a descendant selector here paints its menu entries with the button fill // too — with !important, so the entry rules below cannot undo it. @@ -1760,8 +1770,10 @@ input[type='radio'] { // the seven :not()s are worth seven class-level points, and that beat every // component rule declared later — pagination links and the Clear Filters // button came out --aa-link instead of --aa-text. - a:where(:not(.button):not(.action_item):not(.table_tools_button):not(.dropdown_menu_button):not(.ui-tabs-anchor):not(.delete_link):not([data-method="delete"])) { color: var(--aa-link); } - a[data-method="delete"], a.delete_link { color: var(--aa-delete-link); } + a:where(:not(.button):not(.action_item):not(.table_tools_button):not(.dropdown_menu_button):not(.ui-tabs-anchor):not(.delete_link):not(.delete-link):not([data-method="delete"])) { color: var(--aa-link); } + // `.delete-link` (hyphen) as well as AA's own `.delete_link`: some apps name + // their soft-delete links with the hyphen. + a[data-method="delete"], a.delete_link, a.delete-link { color: var(--aa-delete-link); } // Pagination — non-current page chips use a light-grey bg in the theme .pagination span > a { background-color: var(--aa-surface-2); color: var(--aa-text); } From 3a2ba33b557ec288b154718ba37b0acff696a025 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:16:33 +0200 Subject: [PATCH 06/21] fix: let the pinned title bar contain its content Fixed height: 37px on the flex bar plus an unconditional nowrap title broke containment on both axes: a long record title fixed #titlebar_left's min-content width, pushed the action buttons past the bar's right edge (unreachable on wide pages, where the sticky bar follows horizontal scroll), and action items wrapping on a narrow window overflowed the 37px bar over the content below. min-height instead of height, min-width: 0 down the title chain, and nowrap + ellipsis on the h2 keep the compact row without losing the buttons. --- .../stylesheets/wigu/active_admin_theme.scss | 23 ++++++++++++++----- 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index c21f96f..7693aed 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -674,19 +674,26 @@ body.active_admin { @include viewport-pinned-bar; // Lay the title (left) and action items (right) on one compact row. Without // this, resetting #titlebar_right to static (below) stacks it under the - // title and the bar grows tall. Fixed height so pages with a breadcrumb - // (detail pages) match plain index pages. Drop the base clearfix — in a flex - // container it becomes a stray third item that space-between strands right. + // title and the bar grows tall. min-height, NOT height: with a fixed height + // the bar cannot contain its content — action items that wrap on a narrow + // window would overflow it and paint over the page — while min-height still + // makes breadcrumb (detail) pages match plain index pages. Drop the base + // clearfix — in a flex container it becomes a stray third item that + // space-between strands right. display: flex; align-items: center; justify-content: space-between; - height: 37px; + min-height: 37px; &::after { display: none; } #titlebar_left { display: flex; align-items: center; gap: 6px; height: auto; + // Let the title shrink below its min-content width: flex items refuse to + // otherwise, and a long nowrap record title would push the action items + // out of the pinned bar — unreachable past the viewport on wide pages. + min-width: 0; span.breadcrumb { font-size: 12px; text-shadow: none; @@ -698,14 +705,18 @@ body.active_admin { } // Own a sensible title size so consumer apps don't each shrink AA's large // default (which towered over the breadcrumb). margin:0 + the flex parent - // centre it on the breadcrumb's line; nowrap keeps a long record title - // (show pages) from wrapping the bar. + // centre it on the breadcrumb's line; nowrap + ellipsis keep a long + // record title (show pages) on one line without pushing the action + // items out of the bar — min-width: 0 lets it actually shrink. h2 { color: #ffffff; text-shadow: none; font-size: 16px; margin: 0; + min-width: 0; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } } #titlebar_right { From cb048c9112622e4c292ff98aca924b29be6f0cb7 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:16:50 +0200 Subject: [PATCH 07/21] refactor: drop dead title-bar declarations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit right/top: auto are inert on the position: static box they accompany (offsets apply to positioned elements only), and float: none countered a float that neither AA base nor any consumer path applies — float does not apply to flex items, and span.breadcrumb is a direct flex child. All three delete with no rendering change; as written they sent the next reader hunting for base rules that don't exist. margin-bottom: 0 stays — base sets 12px, that one is live. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 3 --- 1 file changed, 3 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 7693aed..19fde2b 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -697,7 +697,6 @@ body.active_admin { span.breadcrumb { font-size: 12px; text-shadow: none; - float: none; margin-bottom: 0; a, span { color: lighten( $skinMainFirstColor, 30% )!important; @@ -725,8 +724,6 @@ body.active_admin { // consumer `position: absolute/sticky` (each app used to pin the buttons // itself via a local scroll_buttons_init.scss / title_bar override). position: static; - right: auto; - top: auto; // Shrink to the action-items height (some apps/base leave it ~50px, taller // than the 37px bar, so centering pushed it over the top edge). height: auto; From ecd13eb5005ae734f1b30ff1ac42e37699b0c210 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:17:20 +0200 Subject: [PATCH 08/21] feat: promote the title-bar height into the $skin* contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 37px minimum was a hardcoded literal in a theme whose public surface is the README-paired $skin*!default variables — including the very button paddings that size the bar's content: a consumer raising $skinTitleBarButtonPaddingY past the band had no variable to rebalance with. $skinTitleBarHeight: 37px!default follows the $skinTableToolsHeight precedent; css_check's README parity guard covers the new row. --- README.md | 1 + app/assets/stylesheets/wigu/active_admin_theme.scss | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index f884b85..92a5dc6 100644 --- a/README.md +++ b/README.md @@ -343,6 +343,7 @@ Three things changed shape and are worth knowing if you already set variables: | `$skinTitleBarBorderWidth` | `0` | | | `$skinTitleBarButtonPaddingY` | `6px` | action button vertical padding | | `$skinTitleBarButtonPaddingX` | `10px` | action button horizontal padding | +| `$skinTitleBarHeight` | `37px` | minimum bar height; grows to fit content | #### Panels, tabs and labels diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 19fde2b..667045e 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -55,6 +55,9 @@ $skinTitleBarBorderColor: $skinMainSecondColor!default; $skinTitleBarBorderWidth: 0!default; $skinTitleBarButtonPaddingY: 6px!default; // action button vertical padding $skinTitleBarButtonPaddingX: 10px!default; // action button horizontal padding +// Minimum bar height — raise it alongside the button paddings above if your +// buttons outgrow the default band (the bar still grows to fit its content). +$skinTitleBarHeight: 37px!default; // Panel/section headers + tabs accent (the blue bar on panel headers and the // inactive tabs). $skinPanelHeaderColor is the base/light value; @@ -683,7 +686,7 @@ body.active_admin { display: flex; align-items: center; justify-content: space-between; - min-height: 37px; + min-height: $skinTitleBarHeight; &::after { display: none; } #titlebar_left { display: flex; From 85db2d1586c14681b6ac2f515b398a2ba082d5b0 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:17:39 +0200 Subject: [PATCH 09/21] fix: keep long titles wrapping in print MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ActiveAdmin registers the stylesheet with media "all" and the theme has no print scoping, so the title's nowrap leaked into print and cut titles longer than ~85 characters at the page box's right edge — paper has no horizontal scroll, and master wrapped them. Scope the nowrap/ellipsis trio to @media screen; the flex bar itself is harmless in print once the title can wrap. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 667045e..d1195a4 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -716,9 +716,14 @@ body.active_admin { font-size: 16px; margin: 0; min-width: 0; - white-space: nowrap; - overflow: hidden; - text-overflow: ellipsis; + // Screen only: AA registers the stylesheet with media "all", and paper + // has no horizontal scroll — a nowrap/ellipsis title would print + // truncated at the page edge where it used to wrap. + @media screen { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } } } #titlebar_right { From 4fd29dbd300a36ce8071b1c38214240d721c6e54 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:17:55 +0200 Subject: [PATCH 10/21] fix: pin the flash bar to the viewport like its neighbours The flashes bar sits between the two newly pinned bars in the same horizontally scrolling wrapper, so on a wide page scrolled right the user saw a colored band whose text had scrolled off-screen. Apply the same viewport-pinned-bar mixin; AA keeps all padding/background on the inner .flash, so the always-rendered empty wrapper stays inert on flash-less pages. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index d1195a4..d4e74c1 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -796,6 +796,12 @@ body.active_admin { } } .flashes { + // Pin like #header/#title_bar: this is the one full-width bar between the + // two pinned ones, and on a wide page scrolled right its left-aligned text + // would leave the viewport, leaving an unreadable colored stripe. AA puts + // all padding/background on the inner .flash, so the always-rendered empty + // wrapper stays zero-height and inert on flash-less pages. + @include viewport-pinned-bar; .flash { color: #ffffff; background-image: none; From 34cad60a29e9274550812329c1477f8732e49412 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:18:17 +0200 Subject: [PATCH 11/21] docs: document the reachable sticky-header opt-outs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The comment told consumers to opt a listing out with "index class: skip-sticky-headers", but AA 3.x hardcodes the index table class attribute and silently ignores that option — the documented escape hatch did not exist. Name the real ones: a one-line app-wide CSS override for AA listings, .skip-sticky-headers only for self-rendered tables, and .with-sticky-headers to opt hand-built tables in. (README section follows in a separate commit.) --- app/assets/stylesheets/wigu/active_admin_theme.scss | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index d4e74c1..820ffa8 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -1383,8 +1383,14 @@ table.index_table thead th a { // scrolls vertically. The header/title bar pin only horizontally (no `top`), so // they scroll away on vertical scroll and this thead pins to the viewport top — // no overlap. Scoped to .paginated_collection so it only hits real AA index -// listings, not custom report/dashboard tables. Opt a listing out with -// .skip-sticky-headers; opt a non-index table_for in with .with-sticky-headers. +// listings, not custom report/dashboard tables. +// +// Opting out: AA 3.x hardcodes the index table's class attribute and ignores +// `index class:` options, so .skip-sticky-headers is reachable only on tables +// whose markup you render yourself. For real AA listings, opt out app-wide +// with one CSS line in your own stylesheet: +// .paginated_collection table.index_table thead tr th { position: static; } +// Opt a hand-built table IN with .with-sticky-headers. .paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th, table.with-sticky-headers thead tr th { position: sticky; From ff15869d5e5e9258c388afe21e8c82ad3ddaf623 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:18:49 +0200 Subject: [PATCH 12/21] fix: square sticky thead corners so rows don't show through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The corner th carry inner-radius top rounding to nest inside the table's rounded border, but the rounded corners are transparent: once the sticky header detaches on scroll, the rows beneath flicker through the ~3px notches at both ends of the pinned strip. Square the corners for sticky-enabled tables only — the static at-rest artifact (square background over the border rounding) is the lesser of the two, and CSS cannot distinguish stuck from at-rest. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 820ffa8..8690042 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -1397,6 +1397,15 @@ table.with-sticky-headers thead tr th { top: -1px; z-index: 98; } +// Square the corner cells of sticky theads: their inner-radius top corners are +// transparent, so once the header detaches from the table's rounded border the +// rows scrolling beneath flicker through the ~3px notches. At rest the square +// background overpaints the border's rounding by the same ~3px — the lesser, +// static artifact (CSS cannot tell "stuck" from "at rest"). +.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th:first-child, +table.with-sticky-headers thead tr th:first-child { border-top-left-radius: 0; } +.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th:last-child, +table.with-sticky-headers thead tr th:last-child { border-top-right-radius: 0; } // Form section header: a interrupts the fieldset's top border, so the // header bar has no border around it (unlike a panel header). Float the legend From 47f31385ff2e367076c49023e4a7d3a8e16bd627 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:19:12 +0200 Subject: [PATCH 13/21] fix: pin only the first thead row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every thead row's th stuck at the same top: -1px, so a table with a two-row thead (group header over column header) had its header rows overlap while stuck — the standard multi-row-sticky failure that needs per-row offsets CSS alone can't express. table_for emits a single header row, but .with-sticky-headers is public API for hand-built tables once released: scope to tr:first-child so the first row pins and further header rows scroll away normally instead of painting over it. --- .../stylesheets/wigu/active_admin_theme.scss | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 8690042..783ac0e 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -1391,8 +1391,12 @@ table.index_table thead th a { // with one CSS line in your own stylesheet: // .paginated_collection table.index_table thead tr th { position: static; } // Opt a hand-built table IN with .with-sticky-headers. -.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th, -table.with-sticky-headers thead tr th { +// tr:first-child only: with a multi-row thead (group header over column +// header) every row would stick at the same offset and paint over each other — +// per-row offsets need measurements CSS alone can't express. The first row +// pins; any further header rows scroll away normally. +.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr:first-child th, +table.with-sticky-headers thead tr:first-child th { position: sticky; top: -1px; z-index: 98; @@ -1402,10 +1406,10 @@ table.with-sticky-headers thead tr th { // rows scrolling beneath flicker through the ~3px notches. At rest the square // background overpaints the border's rounding by the same ~3px — the lesser, // static artifact (CSS cannot tell "stuck" from "at rest"). -.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th:first-child, -table.with-sticky-headers thead tr th:first-child { border-top-left-radius: 0; } -.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr th:last-child, -table.with-sticky-headers thead tr th:last-child { border-top-right-radius: 0; } +.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr:first-child th:first-child, +table.with-sticky-headers thead tr:first-child th:first-child { border-top-left-radius: 0; } +.paginated_collection table.index_table:not(.skip-sticky-headers) thead tr:first-child th:last-child, +table.with-sticky-headers thead tr:first-child th:last-child { border-top-right-radius: 0; } // Form section header: a interrupts the fieldset's top border, so the // header bar has no border around it (unlike a panel header). Float the legend From af2d5f74eff5328af8cc868b22a0655017acb6d1 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:19:36 +0200 Subject: [PATCH 14/21] fix: keep the scrollbar-width observer alive past Turbo swaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The ResizeObserver was created inside a DOMContentLoaded listener and watched document.body: loaded deferred/async it never attached (DCL already fired), and under Turbo/Turbolinks it kept observing the detached old body after the first navigation — leaving --scrollbar-width stale in both directions. Observe document.documentElement instead: it exists at parse time, survives body swaps, and its client box resizes on the same content growth that toggles the scrollbar. The DCL gate goes away entirely. --- app/assets/javascripts/wigu/scrollbar_width.js | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js index 2aa022b..0636ad7 100644 --- a/app/assets/javascripts/wigu/scrollbar_width.js +++ b/app/assets/javascripts/wigu/scrollbar_width.js @@ -13,11 +13,14 @@ document.documentElement.style.setProperty("--scrollbar-width", (w > 0 ? w : 0) + "px"); } window.addEventListener("resize", update); - document.addEventListener("DOMContentLoaded", function () { - update(); - // The presence of a vertical scrollbar can change after load (content grows/ - // shrinks) without firing resize — watch the body's box too. - if (window.ResizeObserver) { new ResizeObserver(update).observe(document.body); } - }); + // The presence of a vertical scrollbar can change after load (content grows/ + // shrinks) without firing resize. Observe the root element, not : + // documentElement exists at parse time (no DOMContentLoaded gate, so + // deferred/async loading works) and survives Turbo/Turbolinks body swaps, + // where a body observer would keep watching a detached node. Content growth + // resizes the root's client box too — exactly when the scrollbar toggles. + if (window.ResizeObserver) { + new ResizeObserver(update).observe(document.documentElement); + } update(); })(); From 63b47ae62a579e2edee252e79c7466a03772a4e4 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:19:51 +0200 Subject: [PATCH 15/21] perf: skip same-value writes of --scrollbar-width MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit update() wrote the custom property on every event — resize and the ResizeObserver both fire per frame during a drag-resize, and every content growth pings the observer — for a value that only changes when the scrollbar toggles. Same-value dedupe for inline custom properties is engine-dependent and the var feeds the pinned bars' width calc, so guard with a last-value check before setProperty. --- app/assets/javascripts/wigu/scrollbar_width.js | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js index 0636ad7..669dff4 100644 --- a/app/assets/javascripts/wigu/scrollbar_width.js +++ b/app/assets/javascripts/wigu/scrollbar_width.js @@ -8,9 +8,18 @@ // them to the visible viewport instead, so they stay put. Optional: the CSS falls // back to `var(--scrollbar-width, 0px)` = plain 100vw when this script is absent. (function () { + var last; function update() { var w = window.innerWidth - document.documentElement.clientWidth; - document.documentElement.style.setProperty("--scrollbar-width", (w > 0 ? w : 0) + "px"); + if (w < 0) w = 0; + // The value only changes when the scrollbar toggles, but the callers fire + // constantly (resize + ResizeObserver both hit during a drag-resize, and + // every content growth pings the observer). Skip same-value writes: the + // property feeds width: calc(...) of the pinned bars, and redundant inline + // style writes on are restyle churn. + if (w === last) return; + last = w; + document.documentElement.style.setProperty("--scrollbar-width", w + "px"); } window.addEventListener("resize", update); // The presence of a vertical scrollbar can change after load (content grows/ From fe39c454c80e585346517b3593c8e9ecfa43fffa Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:20:08 +0200 Subject: [PATCH 16/21] docs: record why the custom property is bare --scrollbar-width The bare name is the only non --aa-* custom property in the theme and collides two ways with consumer tokens of the same popular name. It is deliberate: legacy consumer scripts set the exact same variable with the same semantics, so the theme works there before the manifest is updated, and renaming to --aa-scrollbar-width would make the require mandatory at upgrade. State the trade-off at the definition site. --- app/assets/javascripts/wigu/scrollbar_width.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js index 669dff4..b55af91 100644 --- a/app/assets/javascripts/wigu/scrollbar_width.js +++ b/app/assets/javascripts/wigu/scrollbar_width.js @@ -7,6 +7,13 @@ // scroll position. Subtracting this variable (0 when there is no scrollbar) sizes // them to the visible viewport instead, so they stay put. Optional: the CSS falls // back to `var(--scrollbar-width, 0px)` = plain 100vw when this script is absent. +// +// The property name is deliberately the bare --scrollbar-width, not --aa-*: +// consumer apps that already set the same variable with the same semantics from +// their own legacy script keep working before their manifest is updated, and +// the theme's CSS picks up either writer. The trade-off is a shared name — an +// app using --scrollbar-width for something else will collide with this +// script's inline writes on ; load one writer, not both. (function () { var last; function update() { From c315e4ccaf424b65ae307823136f00b24010d50d Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:20:35 +0200 Subject: [PATCH 17/21] fix(dummy): build the demo data as locals, not top-level constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WIDE_COLS/WIDE_ROWS were assigned inside the register_page block, which is lexically top-level — they landed on Object, and AA's reloader re-loads every app/admin file on dev reload, re-assigning them with an already-initialized-constant warning each time. Locals inside the content block avoid both the warnings and the namespace leak. --- test/dummy/app/admin/wide_table.rb | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/test/dummy/app/admin/wide_table.rb b/test/dummy/app/admin/wide_table.rb index a1c243a..61c5375 100644 --- a/test/dummy/app/admin/wide_table.rb +++ b/test/dummy/app/admin/wide_table.rb @@ -6,10 +6,13 @@ action_item(:new_record) { link_to "New record", "#" } - WIDE_COLS = (1..12).map { |i| "Column #{i}" }.freeze - WIDE_ROWS = (1..5).map { |r| (1..12).map { |c| "row #{r} / value #{c}" } }.freeze - content do + # Locals, not constants: assignment inside this block is lexically + # top-level (it lands on Object), and AA re-loads every app/admin file on + # dev reload, re-assigning the constant with a warning each time. + cols = (1..12).map { |i| "Column #{i}" } + rows = (1..5).map { |r| (1..12).map { |c| "row #{r} / value #{c}" } } + # nowrap + a min cell width so the 12 columns push the table past the # viewport and the page scrolls sideways (what the sticky bars are for). text_node <<~HTML.html_safe @@ -18,8 +21,8 @@ } HTML panel "A table wider than the viewport" do - head = "" + WIDE_COLS.map { |c| "#{c}" }.join + "" - body = WIDE_ROWS.map do |row| + head = "" + cols.map { |c| "#{c}" }.join + "" + body = rows.map do |row| "" + row.map { |cell| "#{ERB::Util.html_escape(cell)}" }.join + "" end.join text_node( From 8fa58be83d441fe03327dea8b2659fc187df3b5d Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:20:48 +0200 Subject: [PATCH 18/21] fix(dummy): escape header cells like the body cells MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The th interpolation went into the html_safe builder unescaped while the td two lines below is ERB-escaped. The values are static today, but dummy demos are what consumers copy — parameterizing the headers along the existing line would ship unescaped markup into html_safe output. Escape uniformly, matching the sibling table.rb. --- test/dummy/app/admin/wide_table.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/dummy/app/admin/wide_table.rb b/test/dummy/app/admin/wide_table.rb index 61c5375..d690a39 100644 --- a/test/dummy/app/admin/wide_table.rb +++ b/test/dummy/app/admin/wide_table.rb @@ -21,7 +21,7 @@ } HTML panel "A table wider than the viewport" do - head = "" + cols.map { |c| "#{c}" }.join + "" + head = "" + cols.map { |c| "#{ERB::Util.html_escape(c)}" }.join + "" body = rows.map do |row| "" + row.map { |cell| "#{ERB::Util.html_escape(cell)}" }.join + "" end.join From 45785c09f14e784e482b9bdd434d346724197811 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:21:24 +0200 Subject: [PATCH 19/21] test: exercise the sticky-thead selector against a real element MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The feature shipped matched by nothing: the dummy has no database-backed resources (so no AA index table ever renders inside .paginated_collection), the hand-built demo table lacked the opt-in class, and no test visited the demo page — a selector typo or an AA DOM rename would have passed every suite silently. Opt the wide-demo table in via .with-sticky-headers, give it enough rows to scroll, and assert the computed position is sticky. --- test/clock_test.rb | 13 +++++++++++++ test/dummy/app/admin/wide_table.rb | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/test/clock_test.rb b/test/clock_test.rb index fa9bec0..17089de 100644 --- a/test/clock_test.rb +++ b/test/clock_test.rb @@ -50,4 +50,17 @@ def test_clock_renders_two_ticking_lines_and_logout assert_equal Time.zone.tzinfo.identifier, data["data-servertime-zone"] assert_in_delta Time.now.to_i, data["data-servertime-epoch"].to_i, 60 end + + def test_sticky_headers_apply_to_the_demo_table + visit "/admin/wide_table" + assert_selector ".wide-demo thead th", minimum: 12 + # Computed style, not class presence: a selector typo or an AA DOM rename + # would otherwise pass every suite silently — before this test the sticky + # rules matched nothing in the repo's own test surface. + position = page.evaluate_script( + "getComputedStyle(document.querySelector('.wide-demo thead th')).position" + ) + assert_equal "sticky", position, + "the demo table opts in via .with-sticky-headers; its thead should be sticky" + end end diff --git a/test/dummy/app/admin/wide_table.rb b/test/dummy/app/admin/wide_table.rb index d690a39..6f39e64 100644 --- a/test/dummy/app/admin/wide_table.rb +++ b/test/dummy/app/admin/wide_table.rb @@ -11,7 +11,9 @@ # top-level (it lands on Object), and AA re-loads every app/admin file on # dev reload, re-assigning the constant with a warning each time. cols = (1..12).map { |i| "Column #{i}" } - rows = (1..5).map { |r| (1..12).map { |c| "row #{r} / value #{c}" } } + # Enough rows that the page also scrolls vertically, so the sticky thead + # (opted in below) actually sticks and the test can observe it. + rows = (1..40).map { |r| (1..12).map { |c| "row #{r} / value #{c}" } } # nowrap + a min cell width so the 12 columns push the table past the # viewport and the page scrolls sideways (what the sticky bars are for). @@ -26,7 +28,10 @@ "" + row.map { |cell| "#{ERB::Util.html_escape(cell)}" }.join + "" end.join text_node( - (%() + head + "" + body + "
") + # with-sticky-headers: the one place in the repo's test surface where + # the sticky-thead selector matches a real element (AA's own index + # tables need a database-backed resource the dummy doesn't have). + (%() + head + "" + body + "
") .html_safe ) end From 566473a84c5b21413a5bf2969738131d8bbbe064 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:21:59 +0200 Subject: [PATCH 20/21] docs: document scrollbar_width.js and the sticky-header classes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The new opt-in script and both public classes were documented nowhere — every other shipped wigu script has its Sprockets require and npm import in the README. Without the require, consumers keep the ~15px far-right bar shift the PR exists to fix, and the fix was undiscoverable; the sticky headers section also names the working app-wide opt-out (AA 3.x ignores index class:, so the class alone cannot reach real index tables). --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 92a5dc6..b0cd218 100644 --- a/README.md +++ b/README.md @@ -243,6 +243,41 @@ It uses jQuery, which ActiveAdmin already loads. [![Crisscross cell highlighting](./img/crisscross-light.png)](./img/crisscross-light.png) +### Pinned bars and sticky table headers + +On pages with a table wider than the viewport the whole page scrolls sideways; +the theme pins `#header`, `#title_bar` (title and action buttons) and the flash +bar to the visible viewport so they stay put. The bars are sized with `100vw`, +which includes the vertical scrollbar gutter — load the shipped +`scrollbar_width.js` to subtract it, or the bars sit ~15px past the visible +viewport at the far-right scroll position: + +```js +// app/assets/javascripts/active_admin.js +//= require wigu/scrollbar_width + +// or, as an npm module +import "@activeadmin-plugins/active_admin_theme/src/scrollbar_width"; +``` + +Without the script the CSS falls back to plain `100vw` — nothing breaks, you +just keep the small far-right shift. If your app already sets a +`--scrollbar-width` variable from its own script, the theme picks it up; load +one writer, not both. + +Index-table column headers also stick to the viewport top while a long listing +scrolls vertically. This is on by default for ActiveAdmin index tables. AA 3.x +hardcodes the index table's classes (`index class:` is ignored), so opt out +app-wide with one line in your stylesheet: + +```scss +.paginated_collection table.index_table thead tr th { position: static; } +``` + +For tables whose markup you render yourself, `skip-sticky-headers` on an +`index_table` opts out, and `with-sticky-headers` opts any hand-built table in +(single-row theads: only the first header row pins). + ### Trix / ActionText editor Trix ships with ActionText (a Rails built-in), so any ActiveAdmin app can have a From 04085558bc3f7660776a5aca0ccbebf943dff1b6 Mon Sep 17 00:00:00 2001 From: Igor Fedoronchuk Date: Wed, 14 Oct 2026 19:28:43 +0200 Subject: [PATCH 21/21] docs: drop a consumer-internal file name from a comment Public repo comments should not reference consumers' internal file names; 'local stylesheet overrides' says the same thing. --- app/assets/stylesheets/wigu/active_admin_theme.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index 783ac0e..1d266bc 100644 --- a/app/assets/stylesheets/wigu/active_admin_theme.scss +++ b/app/assets/stylesheets/wigu/active_admin_theme.scss @@ -730,7 +730,7 @@ body.active_admin { // Horizontal pinning is handled by #title_bar (sticky + viewport width), // so the action items just sit at the right of the bar. Reset any // consumer `position: absolute/sticky` (each app used to pin the buttons - // itself via a local scroll_buttons_init.scss / title_bar override). + // itself via local stylesheet overrides). position: static; // Shrink to the action-items height (some apps/base leave it ~50px, taller // than the 37px bar, so centering pushed it over the top edge).