Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
955a23a
Pin the header and title bar to the viewport while a wide table scrolls
Fivell Oct 14, 2026
fa32ac5
Use max-width instead of min() so the sticky bar compiles under sassc
Fivell Oct 14, 2026
34a9a9b
Sticky index-table headers via CSS position:sticky
Fivell Oct 14, 2026
8e295d3
Extract the viewport-pinned-bar mixin shared by #header and #title_bar
Fivell Oct 14, 2026
7c3500a
Own the title-bar h2 size, action-item spacing and delete-link in the…
Fivell Oct 14, 2026
3a2ba33
fix: let the pinned title bar contain its content
Fivell Oct 14, 2026
cb048c9
refactor: drop dead title-bar declarations
Fivell Oct 14, 2026
ecd13eb
feat: promote the title-bar height into the $skin* contract
Fivell Oct 14, 2026
85db2d1
fix: keep long titles wrapping in print
Fivell Oct 14, 2026
4fd29db
fix: pin the flash bar to the viewport like its neighbours
Fivell Oct 14, 2026
34cad60
docs: document the reachable sticky-header opt-outs
Fivell Oct 14, 2026
ff15869
fix: square sticky thead corners so rows don't show through
Fivell Oct 14, 2026
47f3138
fix: pin only the first thead row
Fivell Oct 14, 2026
af2d5f7
fix: keep the scrollbar-width observer alive past Turbo swaps
Fivell Oct 14, 2026
63b47ae
perf: skip same-value writes of --scrollbar-width
Fivell Oct 14, 2026
fe39c45
docs: record why the custom property is bare --scrollbar-width
Fivell Oct 14, 2026
c315e4c
fix(dummy): build the demo data as locals, not top-level constants
Fivell Oct 14, 2026
8fa58be
fix(dummy): escape header cells like the body cells
Fivell Oct 14, 2026
45785c0
test: exercise the sticky-thead selector against a real element
Fivell Oct 14, 2026
566473a
docs: document scrollbar_width.js and the sticky-header classes
Fivell Oct 14, 2026
0408555
docs: drop a consumer-internal file name from a comment
Fivell Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -343,6 +378,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

Expand Down
42 changes: 42 additions & 0 deletions app/assets/javascripts/wigu/scrollbar_width.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// Expose the vertical scrollbar width as a CSS custom property
// (--scrollbar-width) on <html>. 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.
Comment on lines +8 to +9
//
// 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 <html>; load one writer, not both.
(function () {
var last;
function update() {
var w = window.innerWidth - document.documentElement.clientWidth;
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 <html> 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/
// shrinks) without firing resize. Observe the root element, not <body>:
// 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();
})();
134 changes: 120 additions & 14 deletions app/assets/stylesheets/wigu/active_admin_theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -380,6 +383,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);
}
Expand Down Expand Up @@ -455,16 +475,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;
width: 100vw;
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;
Expand Down Expand Up @@ -657,21 +672,72 @@ 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.
@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. 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;
min-height: $skinTitleBarHeight;
&::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;
margin-bottom: 8px;
margin-bottom: 0;
a, span {
color: lighten( $skinMainFirstColor, 30% )!important; ;
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 + 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;
// 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 {
// 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 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).
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.
Expand Down Expand Up @@ -730,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;
Expand Down Expand Up @@ -1307,6 +1379,38 @@ 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.
//
// 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.
// 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;
}
// 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: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 <legend> 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.
Expand Down Expand Up @@ -1707,8 +1811,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); }
Expand Down
Binary file added img/titlebar-scroll-after.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added img/titlebar-scroll-before.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
13 changes: 13 additions & 0 deletions test/clock_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
39 changes: 39 additions & 0 deletions test/dummy/app/admin/wide_table.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
# 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", "#" }

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}" }
# 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).
text_node <<~HTML.html_safe
<style>#wrapper .wide-demo td, #wrapper .wide-demo th {
white-space: nowrap; min-width: 150px;
}</style>
HTML
panel "A table wider than the viewport" do
head = "<thead><tr>" + cols.map { |c| "<th>#{ERB::Util.html_escape(c)}</th>" }.join + "</tr></thead>"
body = rows.map do |row|
"<tr>" + row.map { |cell| "<td>#{ERB::Util.html_escape(cell)}</td>" }.join + "</tr>"
end.join
text_node(
# 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).
(%(<table class="index_table with-sticky-headers wide-demo">) + head + "<tbody>" + body + "</tbody></table>")
.html_safe
)
end
end
end
1 change: 1 addition & 0 deletions test/dummy/app/assets/javascripts/active_admin.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
//= require wigu/server_clock
//= require wigu/theme_toggle
//= require wigu/table_crisscross
//= require wigu/scrollbar_width
Loading