From 2b1ef0cec6c451d9259e2a134dd51695897d0eb3 Mon Sep 17 00:00:00 2001 From: feildmaster Date: Sun, 4 Oct 2026 17:10:08 -0500 Subject: [PATCH 1/4] feat: adapt toasts to SimpleToast 3.0 Work in progress, not releasable until SimpleToast 3.0.0 is tagged and the @require and package.json are bumped. - toastCss applies the legacy css option to the element 3.0 returns - UnderScript's default toast look is now classes in toast.css - toast buttons still close the toast after their handler - errorToast forwards timeout - plugin toast warns once per plugin when css is used - tests run against the real library and skip until it is installed Co-Authored-By: Claude Sonnet 5.5 --- src/base/plugin/toast.api.d.ts | 3 + src/base/plugin/toast.js | 16 ++- src/base/styles/toast.css | 27 ++++ src/utils/2.toasts.js | 62 +++++---- src/utils/toastCss.js | 44 ++++++ test/base/plugin/toast.js | 146 +++++++++++++++++++ test/setup/simpletoast.js | 27 ++++ test/utils/toasts.js | 246 +++++++++++++++++++++++++++++++++ 8 files changed, 542 insertions(+), 29 deletions(-) create mode 100644 src/utils/toastCss.js create mode 100644 test/base/plugin/toast.js create mode 100644 test/setup/simpletoast.js create mode 100644 test/utils/toasts.js diff --git a/src/base/plugin/toast.api.d.ts b/src/base/plugin/toast.api.d.ts index 92057d45..e4c19a00 100644 --- a/src/base/plugin/toast.api.d.ts +++ b/src/base/plugin/toast.api.d.ts @@ -20,6 +20,7 @@ type UnderScriptToastClass = string | readonly string[]; type UnderScriptToastCloseReason = 'timeout' | 'dismissed' | 'unknown' | (string & {}); type UnderScriptToast = { + readonly element: HTMLElement; exists(): boolean; setText(text: string): void; close(reason?: string): void; @@ -28,6 +29,7 @@ type UnderScriptToast = { type UnderScriptToastButton = { text: string; className?: UnderScriptToastClass; + /** @deprecated Use `className` instead */ css?: UnderScriptToastButtonStyle; onclick?(this: UnderScriptToast, event: MouseEvent, toast: UnderScriptToast): unknown; }; @@ -35,6 +37,7 @@ type UnderScriptToastButton = { type UnderScriptToastOptions = { text: string; title?: string; + /** @deprecated Use `className` instead */ css?: UnderScriptToastCSS; className?: UnderScriptToastClass | { toast?: UnderScriptToastClass; button?: UnderScriptToastClass }; buttons?: UnderScriptToastButton | readonly UnderScriptToastButton[]; diff --git a/src/base/plugin/toast.js b/src/base/plugin/toast.js index e2fa365b..e205935b 100644 --- a/src/base/plugin/toast.js +++ b/src/base/plugin/toast.js @@ -1,19 +1,27 @@ import wrap from 'src/utils/2.pokemon.js'; import { registerModule } from 'src/utils/plugin.js'; import { toast as basicToast, errorToast } from 'src/utils/2.toasts.js'; +import toArray from 'src/utils/toArray.js'; wrap(() => { const name = 'toast'; - function pluginToast(plugin, data) { - const toast = typeof data === 'object' ? { ...data } : { text: data }; + function pluginToast(plugin, toast) { toast.footer = `${plugin.name} • via UnderScript`; if (toast.error) return errorToast(toast); return basicToast(toast); } function mod(plugin) { - return (data) => pluginToast(plugin, data); + let warned = false; + return (data) => { + const toast = typeof data === 'object' ? { ...data } : { text: data }; + if (!warned && (toast.css || toArray(toast.buttons).some((button) => button?.css))) { + warned = true; + plugin.logger.warn('toast(): `css` is deprecated, please use `className` instead.'); + } + return pluginToast(plugin, toast); + }; } - registerModule(name, mod); + registerModule(name, mod, 'logger'); }); diff --git a/src/base/styles/toast.css b/src/base/styles/toast.css index 0ef5473d..736fc411 100644 --- a/src/base/styles/toast.css +++ b/src/base/styles/toast.css @@ -1,6 +1,33 @@ #AlertToast { height: 0; + .underscript-toast { + background-color: rgba(0, 5, 20, 60%); + font-family: monospace; + text-shadow: none; + + .simpletoast-footer { + text-align: end; + } + } + + .underscript-toast-error { + background-color: rgba(200, 0, 0, 60%); + } + + .underscript-toast-info { + font-family: inherit; + } + + .dismiss { + border: revert; + border-radius: revert; + background: revert; + font-size: inherit; + height: revert; + margin: revert; + } + .dismissable { > span { display: block; diff --git a/src/utils/2.toasts.js b/src/utils/2.toasts.js index e2d77a9b..76d83ea7 100644 --- a/src/utils/2.toasts.js +++ b/src/utils/2.toasts.js @@ -1,13 +1,34 @@ import Translation from 'src/structures/constants/translation.js'; -import { buttonCSS } from './1.variables.js'; import merge from './merge.js'; import eventManager from './eventManager.js'; import toArray from './toArray.js'; +import applyToastCss from './toastCss.js'; let ready = false; eventManager.on('underscript:ready', () => ready = true); +function addToastClass(className, name) { + if (className && typeof className === 'object' && !Array.isArray(className)) { + return { ...className, toast: [name, ...toArray(className.toast)] }; + } + return [name, ...toArray(className)]; +} + +function dismissing(button) { + if (!button) return button; + return { + ...button, + onclick(event, instance) { + try { + if (typeof button.onclick === 'function') button.onclick.call(this, event, instance); + } finally { + if (!event.cancelBubble) instance.close('dismissed'); + } + }, + }; +} + export function blankToast() { return new SimpleToast(); } @@ -26,21 +47,16 @@ export function toast(arg) { ].some((obj) => obj instanceof Translation); const defaults = { footer: 'via UnderScript', - css: { - 'background-color': 'rgba(0,5,20,0.6)', - 'text-shadow': '', - 'font-family': 'monospace', - footer: { - 'text-align': 'end', - }, - }, }; if (ready && isTranslation) preprocess(arg); - const slice = new SimpleToast(merge(defaults, arg)); + const { css, ...options } = merge(defaults, arg); + options.className = addToastClass(options.className, 'underscript-toast'); + if (options.buttons) options.buttons = toArray(options.buttons).map(dismissing); + const slice = new SimpleToast(options); + if (slice.exists()) applyToastCss(slice, css, options.buttons); if (!ready && isTranslation && slice.exists()) { - const el = $('#AlertToast > div:last'); eventManager.on('underscript:ready', () => { - process(slice, el, arg); + process(slice, arg); }); } return slice; @@ -58,13 +74,11 @@ export function errorToast(error) { const lToast = { title: error.name || error.title || Translation.ERROR, text: error.message || error.text || getStack(error.error || error) || error, - css: { - 'background-color': 'rgba(200,0,0,0.6)', - }, - className: error.className, + className: addToastClass(error.className, 'underscript-toast-error'), onClose: error.onClose, footer: error.footer, buttons: error.buttons, + timeout: error.timeout, }; return toast(lToast); } @@ -77,6 +91,7 @@ export function infoToast(arg, key, val = '1') { }; } else if (typeof arg !== 'object') return null; const override = { + className: addToastClass(arg.className, 'underscript-toast-info'), onClose: (...args) => { if (typeof arg.onClose === 'function') { if (arg.onClose(...args)) { @@ -88,9 +103,6 @@ export function infoToast(arg, key, val = '1') { }; const defaults = { title: Translation.INFO, - css: { - 'font-family': 'inherit', - }, }; return toast(merge(defaults, arg, override)); } @@ -100,7 +112,6 @@ export function dismissable({ title, text, key, value = 'true', css = {} }) { const buttons = { text: Translation.DISMISS, className: 'dismiss', - css: buttonCSS, onclick: (e) => { localStorage.setItem(key, value); }, @@ -123,11 +134,12 @@ function preprocess(arg) { }); } -function process(instance, el, { text, title, buttons }) { +function process(instance, { text, title, buttons }) { + const el = instance.element; if (text instanceof Translation) instance.setText(`${text}`); - if (title instanceof Translation) el.find('> span:first').text(title); - const $buttons = el.find('> button'); - toArray(buttons).forEach((button, i) => { - if (button.text instanceof Translation) $buttons[i].textContent = button.text; + if (title instanceof Translation) el.querySelector('.simpletoast-title').textContent = `${title}`; + const rendered = el.querySelectorAll('.simpletoast-button'); + toArray(buttons).filter((button) => button?.text).forEach((button, i) => { + if (button.text instanceof Translation) rendered[i].textContent = `${button.text}`; }); } diff --git a/src/utils/toastCss.js b/src/utils/toastCss.js new file mode 100644 index 00000000..bd483837 --- /dev/null +++ b/src/utils/toastCss.js @@ -0,0 +1,44 @@ +import toArray from './toArray.js'; + +function apply(element, style) { + const previous = {}; + if (!element || !style) return previous; + Object.keys(style).forEach((key) => { + const value = style[key]; + if (typeof value === 'object') return; + previous[key] = element.style[key]; + element.style[key] = value; + }); + return previous; +} + +function hover(element, style) { + if (!Object.keys(style).length) return; + let previous = {}; + element.addEventListener('mouseenter', () => { + previous = apply(element, style); + }); + element.addEventListener('mouseleave', () => { + apply(element, previous); + previous = {}; + }); +} + +export default function applyToastCss({ element }, css, buttons) { + const style = css || {}; + apply(element, style.toast || style); + + const title = element.querySelector('.simpletoast-title'); + if (title.hasChildNodes()) apply(title, style.title); + const footer = element.querySelector('.simpletoast-footer'); + if (footer.hasChildNodes()) apply(footer, style.footer); + + const rendered = element.querySelectorAll('.simpletoast-button'); + toArray(buttons).filter((button) => button?.text).forEach((button, index) => { + const target = rendered[index]; + if (!target) return; + apply(target, style.button); + apply(target, button.css); + hover(target, { ...style.button?.mouseOver, ...button.css?.mouseOver }); + }); +} diff --git a/test/base/plugin/toast.js b/test/base/plugin/toast.js new file mode 100644 index 00000000..1a326e64 --- /dev/null +++ b/test/base/plugin/toast.js @@ -0,0 +1,146 @@ +import { expect } from 'chai'; +import 'src/base/plugin/logger.js'; +import 'src/base/plugin/toast.js'; +import loadSimpleToast, { clearToasts } from '../../setup/simpletoast.js'; + +let counter = 0; + +function setup() { + counter += 1; + const name = `Toast test ${counter}`; + return { name, toast: globalThis.underscript.plugin(name).toast }; +} + +describe('plugin toast', function pluginToastTests() { + const originalWarn = console.warn; // eslint-disable-line no-console + let warnings; + + before(function load() { + if (!loadSimpleToast()) this.skip(); + }); + + beforeEach(() => { + warnings = []; + console.warn = (...args) => warnings.push(args); // eslint-disable-line no-console + }); + + afterEach(() => { + console.warn = originalWarn; // eslint-disable-line no-console + clearToasts(); + }); + + const messages = () => warnings.map((args) => args[args.length - 1]); + + describe('result', () => { + it('returns the toast', () => { + const { toast } = setup(); + const instance = toast({ text: 'hello' }); + expect(instance.exists()).to.equal(true); + expect(instance.element.textContent).to.contain('hello'); + instance.close(); + expect(instance.exists()).to.equal(false); + }); + + it('accepts a plain string', () => { + const { toast } = setup(); + const instance = toast('hello'); + expect(instance.exists()).to.equal(true); + expect(instance.element.querySelector('.simpletoast-body').textContent).to.equal('hello'); + }); + + it('returns an error toast when asked', () => { + const { toast } = setup(); + const instance = toast({ error: true, text: 'broken' }); + expect(instance.exists()).to.equal(true); + expect(instance.element.className).to.contain('underscript-toast-error'); + }); + }); + + describe('footer', () => { + it('names the plugin', () => { + const { name, toast } = setup(); + const footer = toast({ text: 'hello' }).element.querySelector('.simpletoast-footer'); + expect(footer.textContent).to.equal(`${name} • via UnderScript`); + }); + + it('names the plugin on error toasts', () => { + const { name, toast } = setup(); + const footer = toast({ error: true, text: 'broken' }).element.querySelector('.simpletoast-footer'); + expect(footer.textContent).to.equal(`${name} • via UnderScript`); + }); + + it('does not change the object the plugin passed in', () => { + const { toast } = setup(); + const data = { text: 'hello' }; + toast(data); + expect(data).to.deep.equal({ text: 'hello' }); + }); + }); + + describe('css warning', () => { + it('stays quiet when no css is used', () => { + const { toast } = setup(); + toast({ text: 'plain', className: 'mine', buttons: { text: 'b', className: 'btn' } }); + toast('also plain'); + expect(warnings).to.have.length(0); + }); + + it('warns when the toast has css', () => { + const { toast } = setup(); + toast({ text: 'styled', css: { color: 'red' } }); + expect(warnings).to.have.length(1); + expect(messages()[0]).to.contain('css').and.contain('className'); + }); + + it('warns when a button has css', () => { + const { toast } = setup(); + toast({ text: 'styled', buttons: [{ text: 'one' }, { text: 'two', css: { color: 'red' } }] }); + expect(warnings).to.have.length(1); + }); + + it('names the plugin in the warning', () => { + const { name, toast } = setup(); + toast({ text: 'styled', css: { color: 'red' } }); + expect(String(warnings[0][0])).to.contain(name).and.contain('/warn'); + }); + + it('warns once per plugin', () => { + const { toast } = setup(); + toast({ text: 'one', css: { color: 'red' } }); + toast({ text: 'two', css: { color: 'red' } }); + toast({ text: 'three', buttons: { text: 'b', css: { color: 'red' } } }); + expect(warnings).to.have.length(1); + }); + + it('warns each plugin separately', () => { + const first = setup(); + const second = setup(); + first.toast({ text: 'one', css: { color: 'red' } }); + second.toast({ text: 'two', css: { color: 'red' } }); + first.toast({ text: 'three', css: { color: 'red' } }); + expect(warnings).to.have.length(2); + expect(String(warnings[0][0])).to.contain(first.name); + expect(String(warnings[1][0])).to.contain(second.name); + }); + + it('does not use up the warning on toasts without css', () => { + const { toast } = setup(); + toast('plain'); + toast({ text: 'styled', css: { color: 'red' } }); + expect(warnings).to.have.length(1); + }); + + it('still shows the toast, with its css applied', () => { + const { toast } = setup(); + const instance = toast({ text: 'styled', css: { color: 'red' } }); + expect(instance.exists()).to.equal(true); + expect(instance.element.style.color).to.equal('red'); + }); + + it('warns for error toasts too', () => { + const { toast } = setup(); + toast({ error: true, text: 'broken', css: { color: 'red' } }); + expect(warnings).to.have.length(1); + }); + }); +}); diff --git a/test/setup/simpletoast.js b/test/setup/simpletoast.js new file mode 100644 index 00000000..4b3ebfa5 --- /dev/null +++ b/test/setup/simpletoast.js @@ -0,0 +1,27 @@ +import fs from 'node:fs'; +import { createRequire } from 'node:module'; +import vm from 'node:vm'; + +const require = createRequire(import.meta.url); + +function locate() { + if (process.env.SIMPLETOAST_DIST) return process.env.SIMPLETOAST_DIST; + try { + return require.resolve('simpletoast/dist/simpletoast.js'); + } catch { + return null; + } +} + +export default function loadSimpleToast() { + const file = locate(); + if (!file) return false; + const source = fs.readFileSync(file, 'utf8'); + const run = vm.compileFunction(source, ['window', 'document', 'CustomEvent']); + run.call(globalThis, window, document, window.CustomEvent); + return typeof globalThis.SimpleToast === 'function'; +} + +export function clearToasts() { + document.querySelectorAll('.simpletoast').forEach((el) => el.remove()); +} diff --git a/test/utils/toasts.js b/test/utils/toasts.js new file mode 100644 index 00000000..5dcf7acc --- /dev/null +++ b/test/utils/toasts.js @@ -0,0 +1,246 @@ +import { expect } from 'chai'; +import Translation from '../../src/structures/constants/translation.js'; +import { blankToast, dismissable, errorToast, infoToast, toast } from '../../src/utils/2.toasts.js'; +import eventManager from '../../src/utils/eventManager.js'; +import loadSimpleToast, { clearToasts } from '../setup/simpletoast.js'; + +describe('toasts', function toastTests() { + before(function load() { + if (!loadSimpleToast()) this.skip(); + }); + + afterEach(clearToasts); + + const body = (instance) => instance.element.querySelector('.simpletoast-body'); + const buttons = (instance) => [...instance.element.querySelectorAll('.simpletoast-button')]; + + describe('toast', () => { + it('shows text with the UnderScript footer and class', () => { + const instance = toast('Hello'); + expect(instance.exists()).to.equal(true); + expect(body(instance).textContent).to.equal('Hello'); + expect(instance.element.querySelector('.simpletoast-footer').textContent).to.equal('via UnderScript'); + expect(instance.element.classList.contains('underscript-toast')).to.equal(true); + }); + + it('sets no inline styles of its own', () => { + const instance = toast({ title: 'T', text: 'x', buttons: { text: 'b' } }); + expect(instance.element.hasAttribute('style')).to.equal(false); + expect(instance.element.querySelectorAll('[style]')).to.have.length(0); + }); + + it('returns false without arguments', () => { + expect(toast()).to.equal(false); + }); + + it('returns a dead toast from blankToast', () => { + expect(blankToast().exists()).to.equal(false); + }); + + it('keeps the caller classes next to its own', () => { + expect(toast({ text: 'a', className: 'one' }).element.className).to.contain('underscript-toast').and.contain('one'); + expect(toast({ text: 'a', className: ['one', 'two'] }).element.className).to.contain('two'); + const sectioned = toast({ text: 'a', className: { toast: 'one', button: 'btn' }, buttons: { text: 'b' } }); + expect(sectioned.element.className).to.contain('underscript-toast').and.contain('one'); + expect(buttons(sectioned)[0].className).to.contain('btn'); + expect(toast({ text: 'a', className: { button: 'btn' } }).element.className).to.contain('underscript-toast'); + }); + + it('closes on click of the toast', () => { + const instance = toast('a'); + instance.element.click(); + expect(instance.exists()).to.equal(false); + }); + + it('passes timeouts through', () => { + const instance = toast({ text: 'a', timeout: 50, idle: false }); + expect(instance.element.hasAttribute('data-simpletoast-timed')).to.equal(true); + }); + }); + + describe('css option', () => { + it('applies flat css to the toast', () => { + const { element } = toast({ text: 'a', css: { color: 'yellow', 'background-color': 'red' } }); + expect(element.style.color).to.equal('yellow'); + expect(element.style.backgroundColor).to.equal('red'); + }); + + it('prefers css.toast over flat keys, like before', () => { + const { element } = toast({ text: 'a', css: { color: 'yellow', toast: { color: 'blue' } } }); + expect(element.style.color).to.equal('blue'); + }); + + it('applies title and footer css when they have content', () => { + const withBoth = toast({ title: 'T', text: 'a', footer: 'F', css: { title: { color: 'red' }, footer: { color: 'white' } } }); + expect(withBoth.element.querySelector('.simpletoast-title').style.color).to.equal('red'); + expect(withBoth.element.querySelector('.simpletoast-footer').style.color).to.equal('white'); + const withoutTitle = toast({ text: 'a', css: { title: { color: 'red' } } }); + expect(withoutTitle.element.querySelector('.simpletoast-title').hasAttribute('style')).to.equal(false); + }); + + it('applies css.button to every button and button.css to its own', () => { + const instance = toast({ + text: 'a', + css: { button: { color: 'red' } }, + buttons: [{ text: 'one', css: { color: 'green' } }, { text: 'two' }], + }); + const [one, two] = buttons(instance); + expect(one.style.color).to.equal('green'); + expect(two.style.color).to.equal('red'); + }); + + it('lines css up with the rendered buttons when one has no text', () => { + const instance = toast({ + text: 'a', + buttons: [{ text: '', css: { color: 'red' } }, { text: 'shown', css: { color: 'blue' } }], + }); + expect(buttons(instance)).to.have.length(1); + expect(buttons(instance)[0].style.color).to.equal('blue'); + }); + + it('applies mouseOver styles on hover and restores them after', () => { + const instance = toast({ + text: 'a', + css: { button: { color: 'red', mouseOver: { color: 'blue' } } }, + buttons: { text: 'b', css: { mouseOver: { 'background-color': 'black' } } }, + }); + const [button] = buttons(instance); + expect(button.style.color).to.equal('red'); + button.dispatchEvent(new window.MouseEvent('mouseenter')); + expect(button.style.color).to.equal('blue'); + expect(button.style.backgroundColor).to.equal('black'); + button.dispatchEvent(new window.MouseEvent('mouseleave')); + expect(button.style.color).to.equal('red'); + expect(button.style.backgroundColor).to.equal(''); + }); + + it('does not pass css on to the library', () => { + const instance = toast({ text: 'a', css: { color: 'red' } }); + expect(instance.element.getAttribute('css')).to.equal(null); + }); + }); + + describe('buttons', () => { + it('close the toast after their handler, like before', () => { + const calls = []; + const instance = toast({ text: 'a', buttons: { text: 'b', onclick(event, self) { calls.push([this, self]); } } }); + buttons(instance)[0].click(); + expect(calls).to.have.length(1); + expect(calls[0][0]).to.equal(instance); + expect(calls[0][1]).to.equal(instance); + expect(instance.exists()).to.equal(false); + }); + + it('close the toast even without a handler', () => { + const instance = toast({ text: 'a', buttons: { text: 'b' } }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(false); + }); + + it('report the dismissal reason', () => { + let reason; + const instance = toast({ text: 'a', buttons: { text: 'b' }, onClose: (why) => { reason = why; } }); + buttons(instance)[0].click(); + expect(reason).to.equal('dismissed'); + }); + + it('keep the toast open when the handler stops propagation', () => { + const instance = toast({ text: 'a', buttons: { text: 'b', onclick(event) { event.stopPropagation(); } } }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(true); + }); + + it('keep the reason a handler closed with', () => { + let reason; + const instance = toast({ + text: 'a', + buttons: { text: 'b', onclick(event, self) { self.close('custom'); } }, + onClose: (why) => { reason = why; }, + }); + buttons(instance)[0].click(); + expect(reason).to.equal('custom'); + }); + + it('still close the toast when the handler throws', () => { + const instance = toast({ text: 'a', buttons: { text: 'b', onclick() { throw new Error('boom'); } } }); + try { + buttons(instance)[0].click(); + } catch (e) { + expect(e.message).to.equal('boom'); + } + expect(instance.exists()).to.equal(false); + }); + + it('do not touch the caller button objects', () => { + const button = { text: 'b', onclick() {} }; + const original = button.onclick; + toast({ text: 'a', buttons: button }); + expect(button.onclick).to.equal(original); + }); + }); + + describe('errorToast', () => { + it('shows the message with the error class', () => { + const instance = errorToast({ title: 'Oops', message: 'bad' }); + expect(body(instance).textContent).to.equal('bad'); + expect(instance.element.querySelector('.simpletoast-title').textContent).to.equal('Oops'); + expect(instance.element.className).to.contain('underscript-toast-error').and.contain('underscript-toast'); + }); + + it('keeps the caller class', () => { + expect(errorToast({ message: 'bad', className: 'mine' }).element.className).to.contain('mine'); + }); + + it('passes the timeout through', () => { + expect(errorToast({ message: 'bad', timeout: 5000, idle: false }).element.hasAttribute('data-simpletoast-timed')).to.equal(true); + expect(errorToast({ message: 'bad' }).element.hasAttribute('data-simpletoast-timed')).to.equal(false); + }); + }); + + describe('infoToast', () => { + it('shows with the info class and remembers it was closed', () => { + const instance = infoToast('Fyi', 'test.info'); + expect(instance.element.className).to.contain('underscript-toast-info'); + expect(localStorage.getItem('test.info')).to.equal(null); + instance.close(); + expect(localStorage.getItem('test.info')).to.equal('1'); + expect(infoToast('Fyi', 'test.info')).to.equal(null); + }); + }); + + describe('dismissable', () => { + it('stores the key and closes when dismissed', () => { + const instance = dismissable({ title: 'T', text: 'x', key: 'test.dismiss' }); + expect(instance.element.className).to.contain('dismissable'); + const [button] = buttons(instance); + expect(button.className).to.contain('dismiss'); + button.click(); + expect(localStorage.getItem('test.dismiss')).to.equal('true'); + expect(instance.exists()).to.equal(false); + expect(dismissable({ title: 'T', text: 'x', key: 'test.dismiss' })).to.equal(undefined); + }); + }); + + describe('translations', () => { + const text = Translation.General('test.text', 'Translated text'); + const title = Translation.General('test.title', 'Translated title'); + const label = Translation.General('test.label', 'Translated label'); + + it('are filled in when UnderScript becomes ready', () => { + const instance = toast({ title, text, buttons: [{ text: '' }, { text: label }] }); + eventManager.emit('underscript:ready'); + expect(body(instance).textContent).to.equal(`${text}`); + expect(instance.element.querySelector('.simpletoast-title').textContent).to.equal(`${title}`); + expect(buttons(instance)).to.have.length(1); + expect(buttons(instance)[0].textContent).to.equal(`${label}`); + }); + + it('are converted straight away once ready', () => { + eventManager.emit('underscript:ready'); + const instance = toast({ title, text, buttons: { text: label } }); + expect(body(instance).textContent).to.equal(`${text}`); + expect(instance.element.querySelector('.simpletoast-title').textContent).to.equal(`${title}`); + expect(buttons(instance)[0].textContent).to.equal(`${label}`); + }); + }); +}); From b926871ee88aa25d804dbc43a68d7f4ff51d2072 Mon Sep 17 00:00:00 2001 From: feildmaster Date: Sun, 4 Oct 2026 17:45:53 -0500 Subject: [PATCH 2/4] chore: update internal toasts to 3.0 usage --- src/base/chat/context.js | 9 ++----- src/base/chat/largeIconMode.js | 3 --- src/base/chat/legend.js | 5 +--- src/base/chat/legendary.js | 7 +---- src/base/chat/notify.new.js | 12 +-------- src/base/chat/winstreak.js | 5 +--- src/base/friends/requests.js | 9 ++----- src/base/friendship/magic.js | 3 --- src/base/game/hotkeys/endTurn.js | 4 +-- src/base/game/resultToast.js | 5 +--- src/base/plugin/enabled.local.js | 8 +----- src/base/store/quickPacks.js | 6 ++--- src/base/streamer/message.private.js | 3 --- src/base/styles/toast.css | 40 +++++++++++++++++++++------- src/base/underscript/updates.js | 4 +-- src/hooks/updates.js | 4 +-- src/utils/1.variables.js | 8 ------ src/utils/2.toasts.js | 12 ++------- src/utils/debugToast.js | 14 ++-------- src/utils/notifications.js | 6 +---- src/utils/toastClass.js | 8 ++++++ test/utils/toastClass.js | 34 +++++++++++++++++++++++ test/utils/toasts.js | 2 +- 23 files changed, 94 insertions(+), 117 deletions(-) create mode 100644 src/utils/toastClass.js create mode 100644 test/utils/toastClass.js diff --git a/src/base/chat/context.js b/src/base/chat/context.js index 9fcf081f..10b9e1a4 100644 --- a/src/base/chat/context.js +++ b/src/base/chat/context.js @@ -7,7 +7,7 @@ import each from 'src/utils/each.js'; import * as fnUser from 'src/utils/user.js'; import ignoreUser from 'src/utils/ignoreUser.js'; import decode from 'src/utils/decode.js'; -import { buttonCSS, window } from 'src/utils/1.variables.js'; +import { window } from 'src/utils/1.variables.js'; import Translation from 'src/structures/constants/translation.ts'; import isCtrl from 'src/utils/isCtrl'; import styles from './context.css'; @@ -119,19 +119,14 @@ eventManager.on('jQuery', () => { if (!settings.value(key)) { simpleToast({ text: Translation.IGNORED.translate(name), - css: { - 'background-color': 'rgba(208, 0, 0, 0.6)', - }, buttons: [{ - css: buttonCSS, text: Translation.UNDO, - className: 'dismiss', onclick: () => { settings.remove(key); updateIgnoreText(id); }, }], - className: 'dismissable', + className: ['dismissable', 'underscript-toast-danger'], }); ignoreUser(name, key, true); } else { diff --git a/src/base/chat/largeIconMode.js b/src/base/chat/largeIconMode.js index 833df5b8..0023c5f3 100644 --- a/src/base/chat/largeIconMode.js +++ b/src/base/chat/largeIconMode.js @@ -1,5 +1,4 @@ import Translation from 'src/structures/constants/translation.ts'; -import { buttonCSS as css } from 'src/utils/1.variables.js'; import { infoToast } from 'src/utils/2.toasts.js'; import eventManager from 'src/utils/eventManager.js'; import * as settings from 'src/utils/settings/index.js'; @@ -34,8 +33,6 @@ eventManager.on('ChatDetected', () => { const value = setting.value(); const buttons = { text: value ? 'Revert it!' : 'Enable it!', - className: 'dismiss', - css, onclick: (e) => { setting.set(!value); }, diff --git a/src/base/chat/legend.js b/src/base/chat/legend.js index 71dba182..1a8160f5 100644 --- a/src/base/chat/legend.js +++ b/src/base/chat/legend.js @@ -54,9 +54,6 @@ eventManager.on('preChat:getMessageAuto', function legend(data) { } toast({ text: global('translateFromServerJson')(data.message), - css: { - color: 'yellow', - footer: { color: 'white' }, - }, + className: 'underscript-toast-highlight', }); }); diff --git a/src/base/chat/legendary.js b/src/base/chat/legendary.js index 256942b5..6fa4b1f5 100644 --- a/src/base/chat/legendary.js +++ b/src/base/chat/legendary.js @@ -61,12 +61,7 @@ eventManager.on('preChat:getMessageAuto', function drawAnnouncement(data) { const toast = SimpleToast({ text: translateFromServerJson(data.message), - css: { - color: 'yellow', - footer: { - color: 'white', - }, - }, + className: 'underscript-toast-highlight', }); toast.cards = [card]; toast.owner = user; diff --git a/src/base/chat/notify.new.js b/src/base/chat/notify.new.js index cd525619..bd217b62 100644 --- a/src/base/chat/notify.new.js +++ b/src/base/chat/notify.new.js @@ -4,7 +4,6 @@ import { global, globalSet } from 'src/utils/global.js'; import pendingIgnore from 'src/utils/pendingIgnore.js'; import pingRegex from 'src/utils/pingRegex.js'; import { infoToast } from 'src/utils/2.toasts.js'; -import { buttonCSS } from 'src/utils/1.variables.js'; import Translation from 'src/structures/constants/translation.js'; import style from 'src/utils/style.js'; @@ -62,27 +61,18 @@ function notify() { text: Translation.Toast('ping'), buttons: [{ text: Translation.Toast('ping.settings'), - className: 'dismiss', onclick() { pingExtras.show(true); }, }, { text: Translation.Toast('ping.remove'), - className: 'dismiss', onclick() { const words = pingExtras.value(); pingExtras.set(words.filter((word) => word !== '@underscript')); }, }, { text: Translation.DISMISS, - className: 'dismiss', }], - className: 'dismissable', - css: { - button: { - ...buttonCSS, - 'white-space': 'normal', - }, - }, + className: ['dismissable', 'underscript-toast-wrap'], }, 'underscript.alert.ping'); } diff --git a/src/base/chat/winstreak.js b/src/base/chat/winstreak.js index 13533da9..0ca73bf1 100644 --- a/src/base/chat/winstreak.js +++ b/src/base/chat/winstreak.js @@ -59,10 +59,7 @@ eventManager.on('preChat:getMessageAuto', function winstreaks(data) { const toast = toasts.add(SimpleToast({ text: global('translateFromServerJson')(data.message), // timeout: 10000, - css: { - color: 'yellow', - footer: { color: 'white' }, - }, + className: 'underscript-toast-highlight', })); toast.time = Date.now(); }); diff --git a/src/base/friends/requests.js b/src/base/friends/requests.js index 70b6d8e5..6b1b0b2a 100644 --- a/src/base/friends/requests.js +++ b/src/base/friends/requests.js @@ -18,21 +18,16 @@ eventManager.on('Friends:requests', (friends) => { each(friends, (friend, id) => { const key = `underscript.request.${id}`; if (sessionStorage.getItem(key)) return; - const css = { - background: 'inherit', - }; // I need to add a way to clear all styles toast({ title: Translation.Toast('friend.request'), text: friend, buttons: [{ - css, text: ' ', - className: 'glyphicon glyphicon-ok green', + className: 'glyphicon glyphicon-ok green underscript-icon-button', onclick: post.bind(null, id), }, { - css, text: ' ', - className: 'glyphicon glyphicon-remove red', + className: 'glyphicon glyphicon-remove red underscript-icon-button', onclick: post.bind(null, id, false), }], }); diff --git a/src/base/friendship/magic.js b/src/base/friendship/magic.js index 8cc34dd0..231b02a3 100644 --- a/src/base/friendship/magic.js +++ b/src/base/friendship/magic.js @@ -3,7 +3,6 @@ import eventManager from 'src/utils/eventManager.js'; import * as settings from 'src/utils/settings/index.js'; import { toast } from 'src/utils/2.toasts.js'; import getLevel from 'src/utils/getFriendshipLevel.js'; -import { buttonCSS as css } from 'src/utils/1.variables.js'; import Translation from 'src/structures/constants/translation.ts'; const setting = settings.register({ @@ -32,8 +31,6 @@ function getFriendship() { className: 'dismissable', buttons: { text: Translation.General('go!'), - className: 'dismiss', - css, onclick: (e) => { location.href = '/Friendship'; }, diff --git a/src/base/game/hotkeys/endTurn.js b/src/base/game/hotkeys/endTurn.js index e5159641..0774c14b 100644 --- a/src/base/game/hotkeys/endTurn.js +++ b/src/base/game/hotkeys/endTurn.js @@ -3,7 +3,7 @@ import * as settings from 'src/utils/settings/index.js'; import { global } from 'src/utils/global.js'; import { infoToast } from 'src/utils/2.toasts.js'; import Hotkey from 'src/utils/hotkey.class.js'; -import { buttonCSS as css, hotkeys } from 'src/utils/1.variables.js'; +import { hotkeys } from 'src/utils/1.variables.js'; import { BUTTON as MOUSE_BUTTON } from 'src/utils/mouse.js'; import Translation from 'src/structures/constants/translation.ts'; @@ -53,8 +53,6 @@ eventManager.on('PlayingGame', function bindHotkeys() { className: 'dismissable', buttons: { text: Translation.General('settings'), - className: 'dismiss', - css, onclick: (_) => { fullDisable.show(true); }, diff --git a/src/base/game/resultToast.js b/src/base/game/resultToast.js index 781a477b..73055f2e 100644 --- a/src/base/game/resultToast.js +++ b/src/base/game/resultToast.js @@ -23,10 +23,7 @@ eventManager.on('getResult:before', function resultToast() { className: 'skiptranslate', text: '🏠', }, - css: { - 'font-family': 'inherit', - button: { background: 'rgb(0, 0, 20)' }, - }, + className: 'underscript-toast-result', onClose: () => { document.location.href = '/'; }, diff --git a/src/base/plugin/enabled.local.js b/src/base/plugin/enabled.local.js index 5ba3b7e3..8d77e20c 100644 --- a/src/base/plugin/enabled.local.js +++ b/src/base/plugin/enabled.local.js @@ -1,5 +1,4 @@ import Translation from 'src/structures/constants/translation.ts'; -import { buttonCSS as css } from 'src/utils/1.variables.js'; import wrap from 'src/utils/2.pokemon.js'; import { registerModule } from 'src/utils/plugin.js'; import * as settings from 'src/utils/settings/index.js'; @@ -47,20 +46,15 @@ wrap(() => { title: 'New Plugin Detected', text: `"${plugin.name}" has been ${isEnabled ? 'enabled' : 'disabled'} by default.`, error: true, // Make it red. - className: { - toast: 'dismissable', - button: 'dismiss', - }, + className: 'dismissable', buttons: [{ text: Translation.General(isEnabled ? 'disable' : 'enable'), - css, onclick() { enabled.set(!isEnabled); registered.set(true); }, }, { text: Translation.DISMISS, - css, onclick() { enabled.set(isEnabled); registered.set(true); diff --git a/src/base/store/quickPacks.js b/src/base/store/quickPacks.js index 335347db..0c0fe774 100644 --- a/src/base/store/quickPacks.js +++ b/src/base/store/quickPacks.js @@ -8,7 +8,7 @@ import { blankToast, toast as basicToast } from 'src/utils/2.toasts.js'; import * as api from 'src/utils/4.api.js'; import formatNumber from 'src/utils/formatNumber.js'; import { getCollection } from 'src/utils/user.js'; -import { buttonCSS as css, window } from 'src/utils/1.variables.js'; +import { window } from 'src/utils/1.variables.js'; import Item from 'src/structures/constants/item.js'; import length from 'src/utils/length'; import isCtrl from 'src/utils/isCtrl'; @@ -142,8 +142,6 @@ onPage('Packs', async function quickOpenPack() { className: 'dismissable', buttons: { text: 'Stop', - className: 'dismiss', - css, onclick: (e) => { events.emit('cancel'); }, @@ -220,7 +218,7 @@ onPage('Packs', async function quickOpenPack() { // TODO: translation? title: `Results: ${formatNumber(results.packs)} Packs${cardResults.shiny ? ` (${total % 4 ? `${formatNumber(total)}, ` : ''}${formatNumber(cardResults.shiny)} shiny)` : total % 4 ? ` (${formatNumber(total)})` : ''}`, text, - css: { 'font-family': 'inherit' }, + className: 'underscript-toast-inherit', }); // Show cards... I guess diff --git a/src/base/streamer/message.private.js b/src/base/streamer/message.private.js index 87de1e3f..d5d7bb4d 100644 --- a/src/base/streamer/message.private.js +++ b/src/base/streamer/message.private.js @@ -4,7 +4,6 @@ import { global } from 'src/utils/global.js'; import { toast } from 'src/utils/2.toasts.js'; import { debug } from 'src/utils/debug.js'; import each from 'src/utils/each.js'; -import { buttonCSS as css } from 'src/utils/1.variables.js'; import { isMod, name as username } from 'src/utils/user'; import Translation from 'src/structures/constants/translation.js'; import streaming from './0.streamer.js'; @@ -53,10 +52,8 @@ eventManager.on('preChat:getPrivateMessage', function streamerMode(data) { // TODO: translation text: `Message from ${username(user)}`, buttons: [{ - css, // TODO: translation text: 'Open', - className: 'dismiss', onclick: () => { open(user); }, diff --git a/src/base/styles/toast.css b/src/base/styles/toast.css index 736fc411..f4246de1 100644 --- a/src/base/styles/toast.css +++ b/src/base/styles/toast.css @@ -15,17 +15,37 @@ background-color: rgba(200, 0, 0, 60%); } - .underscript-toast-info { + .underscript-toast-info, + .underscript-toast-inherit { font-family: inherit; } - .dismiss { - border: revert; - border-radius: revert; - background: revert; - font-size: inherit; - height: revert; - margin: revert; + .underscript-toast-danger { + background-color: rgba(208, 0, 0, 60%); + } + + .underscript-toast-highlight { + color: yellow; + + .simpletoast-footer { + color: white; + } + } + + .underscript-toast-result { + font-family: inherit; + + .simpletoast-button { + background: rgb(0, 0, 20); + } + } + + .underscript-icon-button { + background: inherit; + } + + .underscript-toast-wrap button { + white-space: normal; } .dismissable { @@ -34,12 +54,14 @@ text-align: center; } - .dismiss { + button { background-color: transparent; border: 1px solid #FFF; + border-radius: 0; display: block; font-family: DTM-Mono, monospace; font-size: 14px; + height: auto; margin: 5px auto; max-width: 80%; min-width: 160px; diff --git a/src/base/underscript/updates.js b/src/base/underscript/updates.js index c5d46fa0..20f48cd9 100644 --- a/src/base/underscript/updates.js +++ b/src/base/underscript/updates.js @@ -3,7 +3,7 @@ import wrap from 'src/utils/2.pokemon.js'; import debugToast from 'src/utils/debugToast'; import { toast as Toast } from 'src/utils/2.toasts.js'; import semver from 'src/utils/version.js'; -import { buttonCSS, scriptVersion, window } from 'src/utils/1.variables.js'; +import { scriptVersion, window } from 'src/utils/1.variables.js'; import createParser from 'src/utils/parser'; import eventManager from 'src/utils/eventManager'; import { @@ -42,8 +42,6 @@ wrap(() => { className: 'dismissable', buttons: [{ text: Translation.Toast('update'), - className: 'dismiss', - css: buttonCSS, onclick() { const url = data.url || `https://github.com/UCProjects/UnderScript/releases/download/${data.version}/undercards.user.js`; window.open(url, 'updateUserScript', 'noreferrer'); diff --git a/src/hooks/updates.js b/src/hooks/updates.js index 65f002e5..d688d19d 100644 --- a/src/hooks/updates.js +++ b/src/hooks/updates.js @@ -7,7 +7,7 @@ import * as settings from 'src/utils/settings'; import each from 'src/utils/each'; import wrap from 'src/utils/2.pokemon'; import Translation from 'src/structures/constants/translation.ts'; -import { buttonCSS, DAY, HOUR, scriptVersion, UNDERSCRIPT } from 'src/utils/1.variables'; +import { DAY, HOUR, scriptVersion, UNDERSCRIPT } from 'src/utils/1.variables'; import sleep from 'src/utils/sleep'; import createParser from 'src/utils/parser'; import DialogHelper from 'src/utils/DialogHelper'; @@ -146,8 +146,6 @@ function notify(text, addButton = false) { className: 'dismissable', buttons: addButton ? { text: keys.button, - className: 'dismiss', - css: buttonCSS, onclick: open, } : undefined, }); diff --git a/src/utils/1.variables.js b/src/utils/1.variables.js index af74e9a0..cd9bd536 100644 --- a/src/utils/1.variables.js +++ b/src/utils/1.variables.js @@ -4,14 +4,6 @@ export const footer = `
via ${UNDERSCRIPT}
`; export const hotkeys = []; export const scriptVersion = GM_info.script.version; -export const buttonCSS = { - border: '', - height: '', - background: '', - 'font-size': '', - margin: '', - 'border-radius': '', -}; /** * @type {globalThis} */ diff --git a/src/utils/2.toasts.js b/src/utils/2.toasts.js index 76d83ea7..5532750b 100644 --- a/src/utils/2.toasts.js +++ b/src/utils/2.toasts.js @@ -2,19 +2,13 @@ import Translation from 'src/structures/constants/translation.js'; import merge from './merge.js'; import eventManager from './eventManager.js'; import toArray from './toArray.js'; +import addToastClass from './toastClass.js'; import applyToastCss from './toastCss.js'; let ready = false; eventManager.on('underscript:ready', () => ready = true); -function addToastClass(className, name) { - if (className && typeof className === 'object' && !Array.isArray(className)) { - return { ...className, toast: [name, ...toArray(className.toast)] }; - } - return [name, ...toArray(className)]; -} - function dismissing(button) { if (!button) return button; return { @@ -107,11 +101,10 @@ export function infoToast(arg, key, val = '1') { return toast(merge(defaults, arg, override)); } -export function dismissable({ title, text, key, value = 'true', css = {} }) { +export function dismissable({ title, text, key, value = 'true' }) { if (localStorage.getItem(key) === value) return undefined; const buttons = { text: Translation.DISMISS, - className: 'dismiss', onclick: (e) => { localStorage.setItem(key, value); }, @@ -121,7 +114,6 @@ export function dismissable({ title, text, key, value = 'true', css = {} }) { text, buttons, className: 'dismissable', - css, }); } diff --git a/src/utils/debugToast.js b/src/utils/debugToast.js index 29ac7032..1ec4f61f 100644 --- a/src/utils/debugToast.js +++ b/src/utils/debugToast.js @@ -1,5 +1,5 @@ import { toast } from './2.toasts.js'; -import merge from './merge.js'; +import addToastClass from './toastClass.js'; import { enabled } from './debug.js'; import { general } from './debugFlags.js'; @@ -14,15 +14,5 @@ export default function debugToast(arg, flag = general) { text: arg, }; } - const defaults = { - background: '#c8354e', - textShadow: '#e74c3c 1px 2px 1px', - css: { 'font-family': 'inherit' }, - button: { - // Don't use buttons, mouseOver sucks - background: '#e25353', - textShadow: '#46231f 0px 0px 3px', - }, - }; - return toast(merge(defaults, arg)); + return toast({ ...arg, className: addToastClass(arg.className, 'underscript-toast-inherit') }); } diff --git a/src/utils/notifications.js b/src/utils/notifications.js index 29bc9ee7..06268c8a 100644 --- a/src/utils/notifications.js +++ b/src/utils/notifications.js @@ -3,7 +3,7 @@ import Translation from 'src/structures/constants/translation.js'; import * as settings from './settings/index.js'; import sleep from './sleep.js'; import { toast } from './2.toasts.js'; -import { buttonCSS as css, window } from './1.variables.js'; +import { window } from './1.variables.js'; import compound from './compoundEvent.js'; const setting = settings.register({ @@ -39,9 +39,7 @@ if (isType() && !setting.value()) { function show() { const buttons = [{ - css, text: Translation.Toast('game.request'), - className: 'dismiss', onclick() { requestPermission().then((result) => { const key = result === 'granted' ? 'allowed' : 'denied'; @@ -49,9 +47,7 @@ function show() { }); }, }, { - css, text: Translation.DISMISS, - className: 'dismiss', onclick() { setting.set(true); }, diff --git a/src/utils/toastClass.js b/src/utils/toastClass.js new file mode 100644 index 00000000..89a37af4 --- /dev/null +++ b/src/utils/toastClass.js @@ -0,0 +1,8 @@ +import toArray from './toArray.js'; + +export default function addToastClass(className, name) { + if (className && typeof className === 'object' && !Array.isArray(className)) { + return { ...className, toast: [name, ...toArray(className.toast)] }; + } + return [name, ...toArray(className)]; +} diff --git a/test/utils/toastClass.js b/test/utils/toastClass.js new file mode 100644 index 00000000..5e4d3c83 --- /dev/null +++ b/test/utils/toastClass.js @@ -0,0 +1,34 @@ +import { expect } from 'chai'; +import addToastClass from '../../src/utils/toastClass.js'; + +describe('addToastClass', () => { + it('returns just the class when there is nothing to add to', () => { + expect(addToastClass(undefined, 'base')).to.deep.equal(['base']); + }); + + it('adds to a string', () => { + expect(addToastClass('mine', 'base')).to.deep.equal(['base', 'mine']); + }); + + it('adds to an array', () => { + expect(addToastClass(['one', 'two'], 'base')).to.deep.equal(['base', 'one', 'two']); + }); + + it('adds to the toast part of a sectioned class name and keeps the button part', () => { + expect(addToastClass({ toast: 'one', button: 'btn' }, 'base')).to.deep.equal({ toast: ['base', 'one'], button: 'btn' }); + expect(addToastClass({ toast: ['one', 'two'] }, 'base')).to.deep.equal({ toast: ['base', 'one', 'two'] }); + }); + + it('creates the toast part when only the button part is given', () => { + expect(addToastClass({ button: 'btn' }, 'base')).to.deep.equal({ toast: ['base'], button: 'btn' }); + }); + + it('does not change what it was given', () => { + const sectioned = { toast: 'one', button: 'btn' }; + const list = ['one']; + addToastClass(sectioned, 'base'); + addToastClass(list, 'base'); + expect(sectioned).to.deep.equal({ toast: 'one', button: 'btn' }); + expect(list).to.deep.equal(['one']); + }); +}); diff --git a/test/utils/toasts.js b/test/utils/toasts.js index 5dcf7acc..2a6e6271 100644 --- a/test/utils/toasts.js +++ b/test/utils/toasts.js @@ -213,7 +213,7 @@ describe('toasts', function toastTests() { const instance = dismissable({ title: 'T', text: 'x', key: 'test.dismiss' }); expect(instance.element.className).to.contain('dismissable'); const [button] = buttons(instance); - expect(button.className).to.contain('dismiss'); + expect(button.textContent).to.equal(`${Translation.DISMISS}`); button.click(); expect(localStorage.getItem('test.dismiss')).to.equal('true'); expect(instance.exists()).to.equal(false); From 73f062a4a016f6d614f4f6eeebc703a3fcf9d29c Mon Sep 17 00:00:00 2001 From: feildmaster Date: Sun, 4 Oct 2026 19:25:07 -0500 Subject: [PATCH 3/4] feat: dismiss option for toast buttons A toast button with dismiss: false no longer closes the toast after its handler runs, and event.preventDefault() keeps it open as well, next to the existing stopPropagation() opt-out. The default is unchanged. Co-Authored-By: Claude Sonnet 5.5 --- src/base/plugin/toast.api.d.ts | 1 + src/utils/2.toasts.js | 4 +-- test/types/plugin.ts | 2 +- test/utils/toasts.js | 49 ++++++++++++++++++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/src/base/plugin/toast.api.d.ts b/src/base/plugin/toast.api.d.ts index e4c19a00..c937ee02 100644 --- a/src/base/plugin/toast.api.d.ts +++ b/src/base/plugin/toast.api.d.ts @@ -31,6 +31,7 @@ type UnderScriptToastButton = { className?: UnderScriptToastClass; /** @deprecated Use `className` instead */ css?: UnderScriptToastButtonStyle; + dismiss?: boolean; onclick?(this: UnderScriptToast, event: MouseEvent, toast: UnderScriptToast): unknown; }; diff --git a/src/utils/2.toasts.js b/src/utils/2.toasts.js index 5532750b..3fd9f6f0 100644 --- a/src/utils/2.toasts.js +++ b/src/utils/2.toasts.js @@ -10,14 +10,14 @@ let ready = false; eventManager.on('underscript:ready', () => ready = true); function dismissing(button) { - if (!button) return button; + if (!button || button.dismiss === false) return button; return { ...button, onclick(event, instance) { try { if (typeof button.onclick === 'function') button.onclick.call(this, event, instance); } finally { - if (!event.cancelBubble) instance.close('dismissed'); + if (!event.cancelBubble && !event.defaultPrevented) instance.close('dismissed'); } }, }; diff --git a/test/types/plugin.ts b/test/types/plugin.ts index f0217439..632752f6 100644 --- a/test/types/plugin.ts +++ b/test/types/plugin.ts @@ -43,7 +43,7 @@ plain.toast({ title: 'Title', text: 'Text', className: { toast: 'dismissable', button: 'dismiss' }, - buttons: [{ text: 'OK', className: 'dismiss', onclick: () => {} }], + buttons: [{ text: 'OK', className: 'dismiss', onclick: () => {} }, { text: 'Stay', dismiss: false }], css: { 'background-color': 'red', footer: { 'text-align': 'end' } }, error: true, }).exists() satisfies boolean; diff --git a/test/utils/toasts.js b/test/utils/toasts.js index 2a6e6271..04bbbb3a 100644 --- a/test/utils/toasts.js +++ b/test/utils/toasts.js @@ -150,6 +150,55 @@ describe('toasts', function toastTests() { expect(instance.exists()).to.equal(true); }); + it('keep the toast open when the handler prevents default', () => { + const instance = toast({ text: 'a', buttons: { text: 'b', onclick(event) { event.preventDefault(); } } }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(true); + }); + + it('keep the toast open when a button sets dismiss to false', () => { + const calls = []; + const instance = toast({ text: 'a', buttons: { text: 'b', dismiss: false, onclick(event, self) { calls.push([this, self]); } } }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(true); + expect(calls).to.have.length(1); + expect(calls[0][0]).to.equal(instance); + expect(calls[0][1]).to.equal(instance); + }); + + it('keep the toast open for a dismiss false button without a handler', () => { + const instance = toast({ text: 'a', buttons: { text: 'b', dismiss: false } }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(true); + }); + + it('still close when a button sets dismiss to true', () => { + const instance = toast({ text: 'a', buttons: { text: 'b', dismiss: true } }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(false); + }); + + it('let a dismiss false handler close the toast itself', () => { + let reason; + const instance = toast({ + text: 'a', + buttons: { text: 'b', dismiss: false, onclick(event, self) { self.close('done'); } }, + onClose: (why) => { reason = why; }, + }); + buttons(instance)[0].click(); + expect(instance.exists()).to.equal(false); + expect(reason).to.equal('done'); + }); + + it('only apply dismiss to the button that sets it', () => { + const instance = toast({ text: 'a', buttons: [{ text: 'stay', dismiss: false }, { text: 'go' }] }); + const [stay, go] = buttons(instance); + stay.click(); + expect(instance.exists()).to.equal(true); + go.click(); + expect(instance.exists()).to.equal(false); + }); + it('keep the reason a handler closed with', () => { let reason; const instance = toast({ From e925131aaa5fe9aeb9004e366e78c55457242638 Mon Sep 17 00:00:00 2001 From: feildmaster Date: Mon, 5 Oct 2026 00:29:59 +0000 Subject: [PATCH 4/4] chore: update translation file --- lang/underscript.json | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/lang/underscript.json b/lang/underscript.json index a84d5f24..16036efa 100644 --- a/lang/underscript.json +++ b/lang/underscript.json @@ -1,10 +1,20 @@ { "en": { + "underscript.command.ban": "Ban a user from public chat", + "underscript.command.ban.usage": "", + "underscript.command.broadcast": "Send an announcement to all connected users", + "underscript.command.broadcast.usage": "", "underscript.command.gg": "Wish your opponent a good game", "underscript.command.scroll": "Scroll the chat back to the bottom", "underscript.command.spectate": "Share a spectate link for your current game", "underscript.command.spectate.note": "/spectate [text (optional)]
Output:
You vs Enemy: url [text]", "underscript.command.spectate.usage": "[text (optional)]", + "underscript.command.to": "Temporarily mute a user and delete their messages", + "underscript.command.to.usage": " ", + "underscript.command.unban": "Remove a public chat ban", + "underscript.command.unban.usage": "", + "underscript.command.unto": "Remove a temporary mute", + "underscript.command.unto.usage": "", "underscript.general.bypass.shift": "SHIFT: Bypass confirmation", "underscript.general.cards.craftable": "Craftable cards", "underscript.general.cards.maxed": "Maxed cards", @@ -44,12 +54,14 @@ "underscript.general.passive": "Passive", "underscript.general.plugins": "Community Plugins", "underscript.general.plugins.author": "Author", + "underscript.general.plugins.available": "Not installed", "underscript.general.plugins.empty": "No plugins listed", "underscript.general.plugins.failed": "Unavailable", "underscript.general.plugins.install": "Install", "underscript.general.plugins.installed": "Installed", "underscript.general.plugins.latest": "Latest", "underscript.general.plugins.name": "Plugin", + "underscript.general.plugins.noMatch": "No plugins match your filters", "underscript.general.plugins.reinstall": "Reinstall", "underscript.general.plugins.stale": "Not running", "underscript.general.plugins.unavailable": "Plugin list unavailable", @@ -65,6 +77,8 @@ "underscript.general.random": "Random", "underscript.general.reconnecting": "Reconnecting...", "underscript.general.refresh": "Refresh Page", + "underscript.general.search": "Search", + "underscript.general.search.plugins": "Search plugins", "underscript.general.settings": "Open Settings", "underscript.general.storage.load": "Click to load ($1)", "underscript.general.storage.note": [ @@ -73,6 +87,8 @@ "* CTRL Click to erase deck" ], "underscript.general.storage.save": "Click to save current deck", + "underscript.general.tabs.next": "Next ($1 of $2)", + "underscript.general.tabs.previous": "Previous ($1 of $2)", "underscript.general.time.local": "$1 local", "underscript.general.undo": "Undo", "underscript.general.unignore": "Unignore",