diff --git a/README.md b/README.md index f884b85..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 @@ -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 diff --git a/app/assets/javascripts/wigu/scrollbar_width.js b/app/assets/javascripts/wigu/scrollbar_width.js new file mode 100644 index 0000000..b55af91 --- /dev/null +++ b/app/assets/javascripts/wigu/scrollbar_width.js @@ -0,0 +1,42 @@ +// 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. +// +// 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() { + 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 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 : + // 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(); +})(); diff --git a/app/assets/stylesheets/wigu/active_admin_theme.scss b/app/assets/stylesheets/wigu/active_admin_theme.scss index e5fb5b9..1d266bc 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; @@ -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); } @@ -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; @@ -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. @@ -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; @@ -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 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. @@ -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); } diff --git a/img/titlebar-scroll-after.png b/img/titlebar-scroll-after.png new file mode 100644 index 0000000..00c4c6b Binary files /dev/null and b/img/titlebar-scroll-after.png differ diff --git a/img/titlebar-scroll-before.png b/img/titlebar-scroll-before.png new file mode 100644 index 0000000..dd2c264 Binary files /dev/null and b/img/titlebar-scroll-before.png differ 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 new file mode 100644 index 0000000..6f39e64 --- /dev/null +++ b/test/dummy/app/admin/wide_table.rb @@ -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 + + HTML + panel "A table wider than the viewport" do + 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 + 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). + (%() + 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