diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0ba255e..a95deec 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -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: diff --git a/CHANGELOG.md b/CHANGELOG.md index 1c2d6cb..2fac286 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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.** diff --git a/library/Tiger/Version.php b/library/Tiger/Version.php index be6cc32..36abe23 100644 --- a/library/Tiger/Version.php +++ b/library/Tiger/Version.php @@ -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'; } diff --git a/modules/analytics/views/scripts/admin/index.phtml b/modules/analytics/views/scripts/admin/index.phtml index 624c81a..c2ceba7 100644 --- a/modules/analytics/views/scripts/admin/index.phtml +++ b/modules/analytics/views/scripts/admin/index.phtml @@ -163,7 +163,7 @@ $mode = ($this->ga['mode'] === 'byo') ? 'byo' : 'broker'; - escape($this->t('analytics.connect_google')) ?> + escape($this->t('analytics.connect_google')) ?> t('analytics.connect_hint') ?> diff --git a/tests/js/tiger.button.test.js b/tests/js/tiger.button.test.js new file mode 100644 index 0000000..81fac5a --- /dev/null +++ b/tests/js/tiger.button.test.js @@ -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 + ')'); diff --git a/themes/puma/assets/js/tiger.button.js b/themes/puma/assets/js/tiger.button.js index 4afd0c1..b7e38ab 100644 --- a/themes/puma/assets/js/tiger.button.js +++ b/themes/puma/assets/js/tiger.button.js @@ -16,6 +16,10 @@ * * 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