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
2 changes: 2 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,8 @@ jobs:
for f in themes/puma/assets/js/*.js; do node --check "$f"; done
- name: Tiger.t() contract
run: node tests/js/tiger.i18n.test.js
- name: TigerButton busy state
run: node tests/js/tiger.button.test.js

# Needs a database — one PHP version is enough; the schema, not the interpreter, is under test.
integration:
Expand Down
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,16 @@ All notable changes to **Tiger Core** (`webtigers/tiger-core`). Format follows

## [Unreleased]

## [1.6.4] — 2026-09-14

### Fixed

- **"Continue with Google" busy state no longer breaks the button's layout.** `TigerButton` prepends a
spinner to buttons without a FontAwesome icon; on the Google button that landed outside the flex
content wrapper and pushed the label onto a second line while the settings saved. Buttons can now
name where the spinner goes with `data-tg-busy-host`; the Google button hosts it after its label.
First tests for `tiger.button.js` (`tests/js/tiger.button.test.js`, in the `javascript` CI job).

## [1.6.3] — 2026-09-14

**Google Analytics connect flow brought in line with Google's OAuth branding and privacy guidelines.**
Expand Down
2 changes: 1 addition & 1 deletion library/Tiger/Version.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
class Tiger_Version
{
/** Current Tiger Core version. Keep in lockstep with the git tag cut for a release. */
const VERSION = '1.6.3';
const VERSION = '1.6.4';
}
2 changes: 1 addition & 1 deletion modules/analytics/views/scripts/admin/index.phtml
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ $mode = ($this->ga['mode'] === 'byo') ? 'byo' : 'broker';
<path fill="none" d="M0 0h48v48H0z"></path>
</svg>
</span>
<span class="gsi-material-button-contents"><?= $this->escape($this->t('analytics.connect_google')) ?></span>
<span class="gsi-material-button-contents" data-tg-busy-host><?= $this->escape($this->t('analytics.connect_google')) ?></span>
</span>
</button>
<span class="text-body-secondary"><?= $this->t('analytics.connect_hint') ?></span>
Expand Down
105 changes: 105 additions & 0 deletions tests/js/tiger.button.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/* SPDX-License-Identifier: BSD-3-Clause
* Copyright (c) 2026 WebTigers. Tiger™ and WebTigers™ are trademarks of WebTigers.
*
* TigerButton busy state (themes/puma/assets/js/tiger.button.js) — where the temporary spinner lands.
*
* A button with no FontAwesome icon gets a spinner injected. Prepending it to the button breaks any
* button with its own internal layout (the Google sign-in button: an absolutely positioned state
* layer plus a flex content wrapper — the spinner became a third child and the label wrapped onto a
* second line). data-tg-busy-host names the element the spinner goes into instead.
*
* node tests/js/tiger.button.test.js
*/
'use strict';

const assert = require('assert');
const fs = require('fs');
const path = require('path');
const vm = require('vm');

/** Minimal element: enough of the DOM for busy()/done() — children, querySelector by attribute/tag. */
function el(tag, attrs, children) {
const node = {
tagName: tag, className: '', attrs: Object.assign({}, attrs || {}), children: children || [],
dataset: {}, disabled: false, textContent: (attrs && attrs.text) || '',
setAttribute(k, v) { this.attrs[k] = String(v); }, removeAttribute(k) { delete this.attrs[k]; },
appendChild(c) { c.parent = this; this.children.push(c); return c; },
insertBefore(c, ref) { c.parent = this; const i = this.children.indexOf(ref); this.children.splice(i < 0 ? this.children.length : i, 0, c); return c; },
remove() { const i = this.parent.children.indexOf(this); this.parent.children.splice(i, 1); },
querySelector(sel) {
const walk = (n) => { for (const c of n.children) { if (match(c, sel)) { return c; } const d = walk(c); if (d) { return d; } } return null; };
return walk(this);
},
};
node.children.forEach((c) => { c.parent = node; });
return node;
}
function match(n, sel) {
return sel.split(',').map((s) => s.trim()).some((s) => {
if (s[0] === '[') { return n.attrs[s.slice(1, -1)] != null; }
if (s.indexOf('.') === 0) { return (' ' + n.className + ' ').indexOf(' ' + s.slice(1) + ' ') >= 0; }
return n.tagName === s.split('.')[0] && (s.indexOf('.') < 0 || match(n, '.' + s.split('.').slice(1).join('.')));
});
}

function boot() {
const document = { createElement: (t) => el(t) };
const window = { performance: { now: () => 0 } };
const src = fs.readFileSync(path.join(__dirname, '../../themes/puma/assets/js/tiger.button.js'), 'utf8');
vm.runInNewContext(src, { window, document, Promise, setTimeout });
return window.TigerButton;
}

let passed = 0;
function test(name, fn) {
try { fn(); passed++; console.log(' ok ' + name); }
catch (e) { console.error(' FAIL ' + name + '\n ' + e.message); process.exitCode = 1; }
}

test('a plain text button gets the spinner prepended to the button itself (unchanged behaviour)', () => {
const TB = boot();
const btn = el('button', { text: 'Save' });
TB.busy(btn);
assert.strictEqual(btn.disabled, true);
assert.strictEqual(btn.children.length, 1);
assert.strictEqual(btn.children[0].attrs['data-tg-injected'], '1');
assert.ok(/fa-spinner/.test(btn.children[0].className));
assert.ok(/\bme-2\b/.test(btn.children[0].className), 'leading spinner spaces to the right');
});

test('with data-tg-busy-host the spinner is appended INSIDE the host, not prepended to the button', () => {
const TB = boot();
const label = el('span', { 'data-tg-busy-host': '', text: 'Continue with Google' });
const wrapper = el('span', {}, [el('span', { class: 'icon' }), label]);
const btn = el('button', { text: 'Continue with Google' }, [el('span', { class: 'state' }), wrapper]);
TB.busy(btn);
assert.strictEqual(btn.children.length, 2, 'button children untouched — the layout is intact');
assert.strictEqual(label.children.length, 1);
const spin = label.children[0];
assert.strictEqual(spin.attrs['data-tg-injected'], '1');
assert.ok(/fa-spinner/.test(spin.className));
assert.ok(/\bms-2\b/.test(spin.className), 'trailing spinner spaces to the left');
});

test('done() removes the hosted spinner and re-enables the button', () => {
const TB = boot();
const label = el('span', { 'data-tg-busy-host': '', text: 'Continue with Google' });
const btn = el('button', { text: 'Continue with Google' }, [label]);
TB.busy(btn); TB.done(btn);
assert.strictEqual(label.children.length, 0);
assert.strictEqual(btn.disabled, false);
assert.strictEqual(btn.attrs['aria-busy'], undefined);
});

test('a button with a FontAwesome icon still swaps the icon and injects nothing', () => {
const TB = boot();
const ic = el('i', {}); ic.className = 'fa-solid fa-floppy-disk me-2';
const btn = el('button', { text: 'Save' }, [ic]);
TB.busy(btn);
assert.strictEqual(btn.children.length, 1);
assert.strictEqual(ic.className, 'me-2 fa-solid fa-spinner fa-spin');
TB.done(btn);
assert.strictEqual(ic.className, 'fa-solid fa-floppy-disk me-2');
});

console.log(process.exitCode ? 'FAILED' : 'ALL PASSED (' + passed + ')');
18 changes: 15 additions & 3 deletions themes/puma/assets/js/tiger.button.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
* <i class="fa-solid fa-floppy-disk me-2" data-ajax="fa-solid fa-cloud-arrow-up"></i>
* Non-FontAwesome classes (spacing like me-2) are preserved across the swap.
*
* Buttons whose icon is NOT FontAwesome (a brand mark as inline SVG, say) can name where the
* temporary spinner goes with data-tg-busy-host on a descendant; it is appended there, inside
* the button's own layout, instead of being prepended to the button and breaking that layout.
*
* Convention: buttons are always <button type="button"> (Tiger never page-POSTs a form),
* so a busy button never accidentally submits.
*/
Expand Down Expand Up @@ -48,11 +52,19 @@
if (ic.dataset.default == null) { ic.dataset.default = ic.className; }
ic.className = faSwap(ic.dataset.default, ic.dataset.ajax || SPINNER);
} else {
// Text-only button — inject a temporary spinner so there's still visible feedback.
// No FontAwesome icon — inject a temporary spinner so there's still visible feedback.
// Into the declared host (appended, after its label) when the button names one;
// otherwise prepended to the button itself.
var host = btn.querySelector('[data-tg-busy-host]');
var spin = document.createElement('i');
spin.className = SPINNER + (btn.textContent.trim() ? ' me-2' : '');
spin.setAttribute('data-tg-injected', '1');
btn.insertBefore(spin, btn.firstChild);
if (host) {
spin.className = SPINNER + (host.textContent.trim() ? ' ms-2' : '');
host.appendChild(spin);
} else {
spin.className = SPINNER + (btn.textContent.trim() ? ' me-2' : '');
btn.insertBefore(spin, btn.firstChild);
}
}
}

Expand Down
Loading