Utilities — Constants, Custom Events, ARIA, Focus, Geometry, Animation
Source
src/web/assets/garnish/src/Garnish.js(lines 32–1193)src/web/assets/garnish/src/UiLayerManager.jssrc/web/assets/garnish/src/EscManager.js(deprecated)
Common Pitfalls
- Using
Garnish.$bodbefore<body>exists — Garnish caches$(document.body)at import time; if loaded in<head>before body, it's empty. - Binding
resizeevent onwindowinstead of usingGarnish.$win.on('resize')— the customresizeevent only works on non-window elements (it uses ResizeObserver). - Calling
Garnish.trapFocusWithin()without later callingreleaseFocusWithin()— focus stays trapped even after the modal/overlay is gone (it is never auto-released;trapFocusWithinonly bindskeydown.focus-trapand only intercepts Tab — not programmatic focus orfocusin). - Setting
aria-hidden="true"(orGarnish.ariaHide()) on a custom overlay while it still contains the focused element — browsers block it ("Blocked aria-hidden ... descendant retained focus"). Move focus to the trigger BEFORE hiding (see ARIA & Focus Management). - Using
ev.keyCodewith magic numbers instead ofGarnish.RETURN_KEYetc. — reduces readability and risks cross-browser issues. - Calling
Garnish.scrollContainerToElement()on a hidden element — offset calculations return 0.
Table of Contents
- Global References
- Key Code Constants
- Other Constants
- Custom jQuery Events
- ARIA & Focus Management
- UiLayerManager
- Geometry & Hit Testing
- Animation
- Form Helpers
- Detection
- Static Event System
Global References
Garnish.$win // $(window)
Garnish.$doc // $(document)
Garnish.$bod // $(document.body)
Garnish.$scrollContainer // $(window) by default, can be overridden
Garnish.rtl // true if <body> has class 'rtl'
Garnish.ltr // true if NOT rtlKey Code Constants
Garnish.BACKSPACE_KEY // 8
Garnish.TAB_KEY // 9
Garnish.CLEAR_KEY // 12
Garnish.RETURN_KEY // 13
Garnish.SHIFT_KEY // 16
Garnish.CTRL_KEY // 17
Garnish.ALT_KEY // 18
Garnish.ESC_KEY // 27
Garnish.SPACE_KEY // 32
Garnish.PAGE_UP_KEY // 33
Garnish.PAGE_DOWN_KEY // 34
Garnish.END_KEY // 35
Garnish.HOME_KEY // 36
Garnish.LEFT_KEY // 37
Garnish.UP_KEY // 38
Garnish.RIGHT_KEY // 39
Garnish.DOWN_KEY // 40
Garnish.DELETE_KEY // 46
Garnish.A_KEY // 65
Garnish.S_KEY // 83
Garnish.CMD_KEY // 91
Garnish.META_KEY // 224Always use these instead of magic numbers:
// Correct
if (ev.keyCode === Garnish.ESC_KEY) { ... }
// Wrong
if (ev.keyCode === 27) { ... }Other Constants
Garnish.PRIMARY_CLICK // 1 (left mouse button)
Garnish.SECONDARY_CLICK // 3 (right mouse button)
Garnish.X_AXIS // 'x'
Garnish.Y_AXIS // 'y'
Garnish.FX_DURATION // 200 (default animation duration in ms)
Garnish.TEXT_NODE // 3 (DOM node type)
// ARIA class markers (used internally for modal background hiding)
Garnish.JS_ARIA_CLASS // 'garnish-js-aria'
Garnish.JS_ARIA_TRUE_CLASS // 'garnish-js-aria-true'
Garnish.JS_ARIA_FALSE_CLASS // 'garnish-js-aria-false'Custom jQuery Events
Garnish registers three custom events via $.event.special:
activate
Fires on click OR keyboard activation (Space/Enter). The primary way to make non-<button> elements keyboard-accessible.
// Bind
$element.on('activate', function (ev) {
// ev.originalEvent is the underlying click/keydown
});
// Or via addListener
this.addListener(this.$element, 'activate', 'handleActivate');
// Shorthand
$element.activate(handler);Behavior:
- On
setup: addstabindex="0"to the element (making it focusable), unless it's<body>or has.disabled - On
click: firesactivateunless.disabledor Ctrl+click on<a>with href - On
keydown: firesactivatefor Space or Enter (only on the bound element, not bubbled children) Garnish.activateEventsMutedflag suppresses activate events globally (set during drag)
textchange
Fires when an input's value actually changes. More reliable than input alone.
// Basic
$input.on('textchange', function () {
console.log('Value changed to:', $(this).val());
});
// With delay (debounce)
$input.on('textchange', { delay: 300 }, function () {
// Fires 300ms after last change
});
// Shorthand
$input.textchange(handler);Behavior:
- Listens to
keypress,keyup,change,bluron the element - Compares current value to stored previous value
- Only fires if the value actually changed
- Optional
delayparameter (via event data) debounces the handler
resize
Element-level resize detection using ResizeObserver.
// Fires when element dimensions change (NOT window resize)
$element.on('resize', function () {
console.log('Element was resized');
});
// Shorthand
$element.resize(handler);Behavior:
- Uses a shared
ResizeObserveracross all elements - Stores previous
{width, height}and only fires when either changes - Does NOT apply to
window(native window resize event handles that) - Suppressed when
Garnish.resizeEventsMutedis true (viaGarnish.muteResizeEvents(callback))
ARIA & Focus Management
Focus Trapping
// Trap Tab/Shift+Tab within a container (for modals/overlays)
Garnish.trapFocusWithin($container);
// Release the trap
Garnish.releaseFocusWithin($container);
// Set focus to first focusable element within container
Garnish.setFocusWithin($container);What trapFocusWithin actually does (and doesn't):
- Binds only
keydown.focus-trapon the container (after callingreleaseFocusWithinfirst to avoid double-binding). OnTAB_KEYit finds:focusabledescendants and wraps: Shift+Tab on the first jumps to the last, Tab on the last jumps to the first. If there are no focusable elements, it does nothing. - It does not redirect programmatic
.focus()calls, and it does not listen forfocusin— so focus can still escape if code (or the browser) moves it outside the container by means other than Tab. It only intercepts the Tab key. - It is not auto-released.
releaseFocusWithin($el)just runs$el.off('.focus-trap')— you must call it yourself on close/destroy, or the keydown handler keeps firing on a hidden container. (Garnish'sModalgets away without an explicit release only becausedestroy()removes the container element entirely, which takes its handlers with it.)
setFocusWithin($container) focuses the first :focusable:not(.checkbox):not(.prevent-autofocus) — so honor .prevent-autofocus on anything that shouldn't grab initial focus. It's a no-op if focus is already inside the container, it checks that the first focusable belongs to the first visible .field (avoids jumping to a CKEditor or other complex field unexpectedly), and as a last resort it sets tabindex="-1" on the container and focuses that.
Focus Queries
// Is focus inside this container?
Garnish.focusIsInside($container); // boolean
// Get first focusable element
Garnish.firstFocusableElement($container); // jQuery
// Get all keyboard-focusable elements (excludes tabindex="-1")
Garnish.getKeyboardFocusableElements($container); // jQuery
// Is this element keyboard-focusable?
Garnish.isKeyboardFocusable(element); // boolean
// Get currently focused element
Garnish.getFocusedElement(); // jQueryModal ARIA Helpers
// Set aria-modal="true" and role="dialog" on container
Garnish.addModalAttributes($container);
// Hide all body children from screen readers (except topmost modal and #notifications)
Garnish.hideModalBackgroundLayers();
// Restore aria-hidden state after modal closes
Garnish.resetModalBackgroundLayerVisibility();
// Manually hide a specific element from AT
Garnish.ariaHide(element);
// Check if element was hidden by Garnish's ARIA system
Garnish.hasJsAriaClass(element);The ARIA hiding system preserves the original aria-hidden state using CSS class markers, so it can be restored correctly even for elements that had aria-hidden="false" or aria-hidden="true" before the modal opened.
hideModalBackgroundLayers() walks body children and applies aria-hidden to each except the topmost modal layer and #notifications (the toast container, which must stay announceable above modals). This is one reason #notifications is a reserved CP DOM ID — a body-level plugin element with id="notifications" is also skipped by the mask, so it stays announceable when a modal opens and breaks the modal's screen-reader isolation. See craftcms skill references/cp.md (Reserved DOM IDs) for the full reserved-ID list.
Closing an overlay: move focus OUT before hiding
When you build a custom modal/slideout/menu and hide it by setting aria-hidden="true" (e.g. via Garnish.ariaHide()) on the overlay or an ancestor, the order matters. Browsers refuse to apply aria-hidden to an element that still contains the focused element, logging:
Blocked aria-hidden on an element because its descendant retained focus. The focus must not be hidden from assistive technology users.
So on close, move focus out first, then hide — not after a close transition:
close: function () {
// 1. Move focus back to the trigger BEFORE hiding
this.$triggerElement.focus();
// 2. Now it's safe to hide from AT
Garnish.ariaHide(this.$overlay); // or this.$overlay.attr('aria-hidden', 'true')
Garnish.releaseFocusWithin(this.$overlay); // drop the Tab trap (see Focus Trapping)
},Garnish.Modal is a useful model for the focus half of this: on close it always returns focus to a resolved, visible trigger — it focuses triggerElement, and if that trigger is itself hidden (e.g. it lives inside a closed DisclosureMenu) it falls back to the menu's [aria-controls] button. Apply the same discipline in custom UI: never restore focus to an element that's about to be (or already) hidden. (Modal itself aria-hides the background layers rather than its own container, so it relies on focus restoration plus display/fade rather than aria-hiding the focused subtree — but the move-focus-to-a-visible-target rule is identical.)
The browser-recommended alternative is the inert attribute (element.inert = true): it both removes the subtree from the tab order / a11y tree and blocks focus, so there's no "retained focus" conflict to sequence around. Garnish itself uses aria-hidden (not inert), so if you adopt inert in custom UI, manage it yourself.
UiLayerManager
Manages a stack of UI layers (base document, modals, HUDs, menus) for keyboard shortcut routing.
// Singleton — always use:
Garnish.uiLayerManagerKey Properties
Garnish.uiLayerManager.layer // Current layer index (0 = base)
Garnish.uiLayerManager.currentLayer // Current layer object
Garnish.uiLayerManager.modalLayers // Array of modal layers
Garnish.uiLayerManager.highestModalLayer // Topmost modal layerMethods
// Add a new layer (called by Modal.show(), HUD.show(), etc.)
Garnish.uiLayerManager.addLayer($container, { bubble: false });
// Remove the top layer
Garnish.uiLayerManager.removeLayer();
// Remove a specific layer by container
Garnish.uiLayerManager.removeLayer($container);
// Register a keyboard shortcut on the current layer
Garnish.uiLayerManager.registerShortcut(Garnish.ESC_KEY, function () {
this.hide();
}.bind(this));
// Register with modifiers
Garnish.uiLayerManager.registerShortcut({
keyCode: Garnish.S_KEY,
ctrl: true, // Cmd on Mac
shift: false,
alt: false,
}, saveHandler);
// Unregister a shortcut
Garnish.uiLayerManager.unregisterShortcut(Garnish.ESC_KEY);Events
| Event | When |
|---|---|
addLayer |
Layer added. ev.layer, ev.$container, ev.options |
removeLayer |
Layer removed |
How It Works
Keydown events on <body> are routed through triggerShortcut():
- Check the top layer's registered shortcuts for a match
- If found,
preventDefaultand call the handler - If not found and
options.bubbleis true, check the next layer down - Handlers receive
ev.bubbleShortcut()to manually bubble when needed
Deprecated aliases:
Garnish.escManager— old escape key manager, still instantiated for BCGarnish.shortcutManager— alias forGarnish.uiLayerManager
Geometry & Hit Testing
// Get element offset (accounts for scroll container)
Garnish.getOffset($element); // { top, left }
// Distance between two points
Garnish.getDist(x1, y1, x2, y2); // number
// Is coordinate over an element?
Garnish.hitTest(x, y, $element); // boolean
// Is the cursor over an element? (from a mouse event)
Garnish.isCursorOver(ev, $element); // booleanAnimation
// Shake an element (validation feedback)
Garnish.shake($element);
Garnish.shake($element, 'margin-left'); // custom property
// Scroll a container to bring an element into view
Garnish.scrollContainerToElement($container, $element);
Garnish.scrollContainerToElement($element); // auto-detect scroll parent
// requestAnimationFrame (with polyfill)
var id = Garnish.requestAnimationFrame(callback);
Garnish.cancelAnimationFrame(id);
// Respect reduced motion
Garnish.prefersReducedMotion(); // boolean
Garnish.getUserPreferredAnimationDuration(300); // 300 or 0 if reduced motion
// Temporarily suppress resize events
Garnish.muteResizeEvents(function () {
// DOM mutations here won't trigger resize events
});Form Helpers
// Find all inputs in a container
Garnish.findInputs($container); // input, text, textarea, select, button
// Get POST data as object { name: value }
Garnish.getPostData($container);
// Get a single input's value as it would be POSTed
Garnish.getInputPostVal($input);
// Handles: unchecked checkboxes (null), multi-selects, null <select>
// Get input's base name (strips brackets)
Garnish.getInputBasename($input); // 'fields' from 'fields[title]'
// Copy input values from source to target container
Garnish.copyInputValues($source, $target);
// Copy text styles (font, size, etc.) from one element to another
Garnish.copyTextStyles($source, $target);Detection
// Primary click (left button, no Ctrl/Meta)
Garnish.isPrimaryClick(ev); // boolean
// Ctrl key (or Cmd on Mac)
Garnish.isCtrlKeyPressed(ev); // boolean
// Mobile browser detection
Garnish.isMobileBrowser(); // boolean (phones only)
Garnish.isMobileBrowser(true); // boolean (phones + tablets)
// Element attribute check
Garnish.hasAttr($element, 'data-foo'); // boolean
// Type checks
Garnish.isJquery(val); // boolean
Garnish.isString(val); // boolean
Garnish.isTextNode(elem); // boolean
Garnish.isArray(val); // boolean (deprecated, use Array.isArray)
// Is element a <script> or <style>?
Garnish.isScriptOrStyleElement(element); // boolean
// Clamp a number within a range
Garnish.within(num, min, max); // number
// Get the body's scrollTop (Safari bounce-safe)
Garnish.getBodyScrollTop(); // number
// Get first element from array or jQuery collection
Garnish.getElement(collection); // DOM elementStatic Event System
Listen to events on all instances of a Garnish class:
// Listen to ALL instances of a class
Garnish.on(Garnish.Modal, 'show', handler);
Garnish.on(Garnish.Modal, 'show', data, handler);
// Remove
Garnish.off(Garnish.Modal, 'show', handler);
// Once
Garnish.once(Garnish.Modal, 'show', handler);Events support namespace syntax:
Garnish.on(Garnish.Modal, 'show.myPlugin', handler);
Garnish.off(Garnish.Modal, 'show.myPlugin', handler);These handlers fire when any instance of the class (or subclass) calls this.trigger('show'). Useful for global reactions to widget state changes.
See Also
class-system.md— Instance events (on/off/trigger), addListener (uses namespaces documented here), Garnish.Baseui-widgets.md— Widgets that use UiLayerManager, ARIA helpers, focus trapping, and key constantsdrag-system.md— Drag classes that use axis constants, hitTest, requestAnimationFrameintegration.md— How custom jQuery events (activate, textchange) are used in Twig templates