Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -261,9 +261,7 @@ 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.
just keep the small far-right shift.

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
Expand All @@ -278,6 +276,9 @@ 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).

`//= require wigu/all` pulls all four shipped scripts at once; each stays
individually requirable.

### Trix / ActionText editor

Trix ships with ActionText (a Rails built-in), so any ActiveAdmin app can have a
Expand Down
10 changes: 10 additions & 0 deletions app/assets/javascripts/wigu/all.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Umbrella require for every script the theme ships, so a Sprockets manifest
// carries one line instead of a hand-maintained list that silently drifts:
// theme CSS can depend on a JS-set property (scrollbar_width feeds the pinned
// bars' width), and a missed require degrades visually with no error. Each
// script stays individually requirable for apps that want a subset; see the
// README for what each one does and how to opt out.
//= require wigu/theme_toggle
//= require wigu/server_clock
//= require wigu/scrollbar_width
//= require wigu/table_crisscross
88 changes: 59 additions & 29 deletions app/assets/javascripts/wigu/scrollbar_width.js
Original file line number Diff line number Diff line change
@@ -1,42 +1,72 @@
// 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.
// (--scrollbar-width) on the pinned bars. The sticky #header, #title_bar and
// .flashes 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 value is written on the three consuming bars, NOT on <html>: a custom
// property changed on the root invalidates style for the entire document —
// one full recalc of a 500-row index table per write — while var() resolves
// from the element itself just as well, confining invalidation to three tiny
// subtrees.
//
// 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.
// consumer apps that already set the same variable with the same semantics
// from their own legacy script keep working before their manifest is updated
// (a root-level legacy value still reaches the bars through inheritance when
// this script is absent). The trade-off is a shared name — load one writer,
// not both.
(function () {
var last;
"use strict";

var BARS = "#header, #title_bar, .flashes";
var last = null;

// Idempotent and cheap: reads the inline style (no layout) and writes only
// real changes, so it is safe to call on every observer delivery — which is
// what picks up bars parsed after the value was first computed, and bars
// replaced by a Turbo/Turbolinks body swap.
function apply() {
if (last === null) return;
var val = last + "px";
var bars = document.querySelectorAll(BARS);
for (var i = 0; i < bars.length; i++) {
if (bars[i].style.getPropertyValue("--scrollbar-width") !== val) {
bars[i].style.setProperty("--scrollbar-width", val);
}
}
}

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");
apply();
}
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) {
// The observer alone is enough: it delivers once right after observe()
// (post-layout, pre-paint, so the read forces nothing) and again whenever
// the root's client box changes — which covers window resizes AND content
// growth toggling the scrollbar. A resize listener on top of it would
// force a synchronous style+layout per event during a window drag (the
// clientWidth read lands before the frame's layout) for no extra
// information. documentElement exists at parse time and survives body
// swaps, so there is no DOMContentLoaded gate and nothing goes stale.
new ResizeObserver(update).observe(document.documentElement);
} else {
// No ResizeObserver: fall back to resize events plus one eager read.
window.addEventListener("resize", update);
update();
}
update();
// A body swap replaces the bars the value was written on without
// necessarily resizing the root; re-apply the cached value to the fresh
// elements.
document.addEventListener("turbo:load", apply);
document.addEventListener("turbolinks:load", apply);
})();
20 changes: 19 additions & 1 deletion app/assets/stylesheets/wigu/active_admin_theme.scss
Original file line number Diff line number Diff line change
Expand Up @@ -738,11 +738,17 @@ body.active_admin {
// 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; }
// The form selectors cover button_to action items: Rails renders them as
// form > input[type=submit] (or button), which the anchor selectors
// miss — consumer apps were re-hardcoding the 6px/10px padding to
// compensate, desyncing from the $skin* knobs on every change.
// `> 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.
.action_item > a,
.action_item > .dropdown_menu > a {
.action_item > .dropdown_menu > a,
.action_item > form > input[type="submit"],
.action_item > form > button {
background-image: none;
border: none;
box-shadow: none;
Expand All @@ -759,6 +765,18 @@ body.active_admin {
}
div.dropdown_menu {
div.dropdown_menu_list_wrapper {
// Always right-align title-bar dropdowns, overriding AA's inline
// positioning. AA 3.x's _position() sets inline `left` OR `right`
// per open depending on a window-geometry branch and never clears
// the other, and the wrapper persists across opens — after a resize
// between opens both offsets accumulate and the width:auto wrapper
// stretches from the stale left coordinate to the right edge.
// Title-bar action items sit at the bar's right edge, so pinning
// right is also simply the correct alignment. Consumer apps used to
// carry exactly this pair as a local hotfix; the theme owns the bar
// now, so it owns the guard.
left: auto !important;
right: 0 !important;
background-color: var(--aa-elevated);
border: 1px solid var(--aa-border);
background-image: none;
Expand Down
Loading