Skip to content
Open
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
21 changes: 20 additions & 1 deletion nx2/blocks/shared/toast/toast.css
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
:host {
display: block;
box-sizing: border-box;
max-width: min(22rem, calc(100vw - 2 * var(--s2-spacing-300)));
max-width: min(var(--nx-toast-max-width, 22rem), calc(100vw - 2 * var(--s2-spacing-300)));
margin: 0;
padding: 0;
border: 0;
Expand Down Expand Up @@ -30,6 +30,12 @@
background: var(--s2-red-900);
color: var(--s2-gray-25);
}

&.toast-warning {
border-color: var(--s2-orange-800);
background: var(--s2-orange-700);
color: var(--s2-gray-25);
}
}

.text {
Expand All @@ -42,6 +48,19 @@
white-space: pre-line;
}

.cta {
flex-shrink: 0;
color: inherit;
font-size: var(--s2-component-m-medium-font-size, 14px);
font-weight: bold;
text-decoration: underline;
white-space: nowrap;

&:hover {
opacity: 0.85;
}
}

.close {
flex-shrink: 0;
display: inline-flex;
Expand Down
25 changes: 16 additions & 9 deletions nx2/blocks/shared/toast/toast.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { loadHrefSvg } from '../../../utils/svg.js';

export const VARIANT_SUCCESS = 'success';
export const VARIANT_ERROR = 'error';
export const VARIANT_WARNING = 'warning';

const CLOSE_ICON_URL = new URL('../../../img/icons/S2_Icon_Close_20_N.svg', import.meta.url).href;

Expand Down Expand Up @@ -37,6 +38,7 @@ class NxToast extends LitElement {
static properties = {
message: { type: String, attribute: false },
variant: { type: String, attribute: false },
cta: { attribute: false },
_closeIcon: { state: true },
};

Expand All @@ -48,8 +50,10 @@ class NxToast extends LitElement {
super.connectedCallback();
this.shadowRoot.adoptedStyleSheets = [styles];
this.style.pointerEvents = 'auto';
const ms = Math.max(6000, Number(this.duration) || 6000);
this._timerId = window.setTimeout(this.dismiss, ms);
if (this.duration !== null) {
const ms = Math.max(6000, Number(this.duration) || 6000);
this._timerId = window.setTimeout(this.dismiss, ms);
}
this._loadIcon();
}

Expand All @@ -75,12 +79,13 @@ class NxToast extends LitElement {
const text = this.message?.trim();
if (!text) return nothing;
const isError = this.variant === VARIANT_ERROR;
const isWarning = this.variant === VARIANT_WARNING;
const variantClass = isError || isWarning ? this.variant : VARIANT_SUCCESS;
const role = isError || isWarning ? 'alert' : 'status';
return html`
<div
class="toast toast-${isError ? VARIANT_ERROR : VARIANT_SUCCESS}"
role=${isError ? 'alert' : 'status'}
>
<div class="toast toast-${variantClass}" role=${role}>
<p class="text">${text}</p>
${this.cta?.href ? html`<a class="cta" href=${this.cta.href}>${this.cta.text}</a>` : nothing}
<button
type="button"
class="close"
Expand All @@ -92,13 +97,15 @@ class NxToast extends LitElement {
}
}

export function showToast({ text, variant = VARIANT_SUCCESS, timeout = 6000 } = {}) {
export function showToast({ text, variant = VARIANT_SUCCESS, cta, timeout = 6000, maxWidth } = {}) {
const messageText = text?.trim();
if (!messageText) return;
const toast = document.createElement('nx-toast');
toast.message = messageText;
toast.variant = variant === VARIANT_ERROR ? VARIANT_ERROR : VARIANT_SUCCESS;
toast.duration = timeout;
toast.variant = [VARIANT_ERROR, VARIANT_WARNING].includes(variant) ? variant : VARIANT_SUCCESS;
toast.cta = cta;
toast.duration = timeout; // null = indefinite (no auto-dismiss)
if (maxWidth) toast.style.setProperty('--nx-toast-max-width', maxWidth);
ensureHost().append(toast);
}

Expand Down
1 change: 1 addition & 0 deletions nx2/scripts/nx.js
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ export async function loadArea({ area } = { area: document }) {

if (!isSession) loadSession();
import('../utils/favicon.js');
import('../utils/org-check.js');
}
}

Expand Down
56 changes: 56 additions & 0 deletions nx2/utils/org-check.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { DA_ADMIN } from './utils.js';
import { daFetch } from './api.js';
import { showToast, VARIANT_WARNING } from '../blocks/shared/toast/toast.js';

const DEF_SANDBOX = 'aem-sandbox';
const SANDBOX_FRAGMENT = '/fragments/toasts/sandbox';

async function getSandboxContent() {
const resp = await fetch(`${SANDBOX_FRAGMENT}.plain.html`);
if (!resp.ok) return null;
const html = await resp.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const link = doc.body.querySelector('a');
const cta = link ? {
text: link.textContent.trim(),
href: `${new URL(link.href).pathname}${window.location.search}${window.location.hash}`,
} : null;
link?.remove();
const text = doc.body.textContent.trim();
return text ? { text, cta } : null;
}

async function getIsSandbox(org) {
const confResp = await daFetch({ url: `${DA_ADMIN}/config/${org}/` });
const { status } = confResp;

if (status === 403 || status === 401) return false;

if (status === 200) {
const json = await confResp.json();
if (json.permissions) return false;
}

const listResp = await daFetch({ url: `${DA_ADMIN}/list/${org}` });
const listJson = await listResp.json();
return listJson.length > 0;
}

async function orgCheck() {
const { pathname, hash } = window.location;
if (pathname.startsWith('/app')) return;
if (!hash) return;
const hashVal = hash.replace('#', '');
if (!hashVal.startsWith('/')) return;
const [org] = hashVal.substring(1).split('/');
if (!org || org === DEF_SANDBOX) return;
const isSandbox = await getIsSandbox(org);
if (!isSandbox) return;

const content = await getSandboxContent();
if (!content) return;
showToast({ text: content.text, cta: content.cta, variant: VARIANT_WARNING, timeout: null, maxWidth: '42rem' });
}

orgCheck();
window.addEventListener('hashchange', orgCheck);
102 changes: 102 additions & 0 deletions test/nx2/blocks/shared/toast/toast.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { expect } from '@esm-bundle/chai';
import {
showToast,
VARIANT_SUCCESS,
VARIANT_ERROR,
VARIANT_WARNING,
} from '../../../../../nx2/blocks/shared/toast/toast.js';

const HOST_SELECTOR = '#nx-toast-host';
const getHost = () => document.querySelector(HOST_SELECTOR);
const getToast = () => document.querySelector('nx-toast');

async function toastFor(opts) {
showToast(opts);
const toast = getToast();
await toast.updateComplete;
return toast;
}

describe('showToast', () => {
afterEach(() => {
getHost()?.remove();
});

it('creates the host region and appends an nx-toast', async () => {
const toast = await toastFor({ text: 'Saved' });
const host = getHost();
expect(host).to.exist;
expect(host.getAttribute('role')).to.equal('region');
expect(host.getAttribute('aria-label')).to.equal('Notifications');
expect(toast).to.exist;
expect(toast.message).to.equal('Saved');
});

it('is a no-op when text is missing, empty, or whitespace-only', () => {
showToast({ text: '' });
showToast({ text: ' ' });
showToast({});
expect(getToast()).to.be.null;
});

it('trims surrounding whitespace from the text', async () => {
const toast = await toastFor({ text: ' Saved ' });
expect(toast.message).to.equal('Saved');
expect(toast.shadowRoot.querySelector('.text').textContent).to.equal('Saved');
});

it('defaults to the success variant', async () => {
const toast = await toastFor({ text: 'Saved' });
const inner = toast.shadowRoot.querySelector('.toast');
expect(inner.classList.contains(`toast-${VARIANT_SUCCESS}`)).to.be.true;
expect(inner.getAttribute('role')).to.equal('status');
});

it('applies the error variant class and role="alert"', async () => {
const toast = await toastFor({ text: 'Boom', variant: VARIANT_ERROR });
const inner = toast.shadowRoot.querySelector('.toast');
expect(inner.classList.contains(`toast-${VARIANT_ERROR}`)).to.be.true;
expect(inner.getAttribute('role')).to.equal('alert');
});

it('applies the warning variant class and role="alert"', async () => {
const toast = await toastFor({ text: 'Careful', variant: VARIANT_WARNING });
const inner = toast.shadowRoot.querySelector('.toast');
expect(inner.classList.contains(`toast-${VARIANT_WARNING}`)).to.be.true;
expect(inner.getAttribute('role')).to.equal('alert');
});

it('falls back to the success variant for unknown values', async () => {
const toast = await toastFor({ text: 'Saved', variant: 'bogus' });
expect(toast.variant).to.equal(VARIANT_SUCCESS);
const inner = toast.shadowRoot.querySelector('.toast');
expect(inner.classList.contains(`toast-${VARIANT_SUCCESS}`)).to.be.true;
});

it('renders a CTA link when cta.href and cta.text are provided', async () => {
const toast = await toastFor({
text: 'Saved',
cta: { href: '/next', text: 'View' },
});
const cta = toast.shadowRoot.querySelector('a.cta');
expect(cta).to.exist;
expect(cta.getAttribute('href')).to.equal('/next');
expect(cta.textContent).to.equal('View');
});

it('does not render a CTA when omitted', async () => {
const toast = await toastFor({ text: 'Saved' });
expect(toast.shadowRoot.querySelector('a.cta')).to.be.null;
});

it('dismisses the toast when the close button is clicked', async () => {
const toast = await toastFor({ text: 'Saved' });
toast.shadowRoot.querySelector('button.close').click();
expect(getToast()).to.be.null;
});

it('sets --nx-toast-max-width as an inline style when maxWidth is passed', async () => {
const toast = await toastFor({ text: 'Saved', maxWidth: '320px' });
expect(toast.style.getPropertyValue('--nx-toast-max-width')).to.equal('320px');
});
});
Loading