diff design_system/test/storybook_test.js @ 252:7a7581f040e8

[ui] Add scoped notification component Co-authored-by: Copilot <[email protected]>
author MrJuneJune <me@mrjunejune.com>
date Tue, 04 Aug 2026 11:57:16 -0700
parents 117c4d53c9a4
children fdf3816959cb
line wrap: on
line diff
--- a/design_system/test/storybook_test.js	Tue Aug 04 09:14:57 2026 -0700
+++ b/design_system/test/storybook_test.js	Tue Aug 04 11:57:16 2026 -0700
@@ -125,6 +125,7 @@
       '/components/card',
       '/components/alert',
       '/components/field',
+      '/components/notifications',
       '/components/stack',
     ]) {
       const response = await fetch(`${baseUrl}${route}`);
@@ -350,6 +351,386 @@
     assert.equal(authoredDismissState.connected, true);
     assert.equal(authoredDismissState.events, 0);
 
+    await page.goto(`${baseUrl}/components/notifications`, {
+      waitUntil: 'networkidle',
+    });
+    const notificationScope = page.locator('zen-notifications').first();
+    const notificationPosition = await notificationScope.locator(
+      '[data-zen-notification-stack]',
+    ).evaluate(stack => {
+      const style = getComputedStyle(stack);
+      return {
+        bottom: style.bottom,
+        position: style.position,
+        right: style.right,
+      };
+    });
+    assert.equal(notificationPosition.position, 'fixed');
+    assert.notEqual(notificationPosition.bottom, 'auto');
+    assert.notEqual(notificationPosition.right, 'auto');
+
+    const dedupe = await notificationScope.evaluate(scope => {
+      const source = scope.querySelector('[data-notification-demo]');
+      const send = detail => source.dispatchEvent(
+        new CustomEvent('zen-notify', {
+          bubbles: true,
+          composed: false,
+          detail,
+        }),
+      );
+      send({
+        version: 1,
+        id: 'dedupe',
+        tone: 'info',
+        message: 'First announcement',
+        announcement: 'polite',
+        persistent: true,
+      });
+      send({
+        version: 1,
+        id: 'dedupe',
+        tone: 'success',
+        message: 'Updated without duplication',
+        announcement: 'polite',
+        persistent: true,
+      });
+      send({ version: 99, id: 'invalid' });
+      return {
+        size: scope.size,
+        visible: scope.visibleCount,
+      };
+    });
+    assert.deepEqual(dedupe, { size: 1, visible: 1 });
+    assert.equal(
+      await notificationScope.locator('article').count(),
+      1,
+    );
+    assert.match(
+      await notificationScope.locator('article').textContent(),
+      /Updated without duplication/,
+    );
+    await page.waitForFunction(() =>
+      document.querySelector(
+        'zen-notifications [data-zen-live="polite"]',
+      )?.textContent === 'First announcement'
+    );
+
+    const bounded = await notificationScope.evaluate(scope => {
+      let accepted = 0;
+      for (let index = 0; index < 25; index++) {
+        if (scope.notify({
+          version: 1,
+          id: `bounded-${index}`,
+          tone: 'info',
+          message: `Bounded ${index}`,
+          announcement: 'none',
+          durationMs: 120000,
+        })) accepted++;
+      }
+      return {
+        accepted,
+        size: scope.size,
+        visible: scope.visibleCount,
+      };
+    });
+    assert.deepEqual(bounded, { accepted: 25, size: 23, visible: 3 });
+    assert.equal(
+      await notificationScope.locator('article').count(),
+      3,
+    );
+
+    const actionScope = await page.evaluate(() => {
+      const scope = document.createElement('zen-notifications');
+      scope.id = 'action-scope';
+      const source = document.createElement('button');
+      scope.append(source);
+      document.body.append(scope);
+      window.__notificationAction = null;
+      scope.addEventListener('zen-notification-action', event => {
+        window.__notificationAction = event.detail;
+      });
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'action',
+          tone: 'error',
+          message: 'Action required',
+          announcement: 'assertive',
+          persistent: true,
+          action: {
+            token: 'opaque-secret-token',
+            label: 'Retry',
+          },
+        },
+      }));
+      return scope.id;
+    });
+    assert.equal(
+      await page.locator(`#${actionScope}`).evaluate(
+        scope => scope.records,
+      ),
+      undefined,
+    );
+    const actionArticle = page.locator(
+      `#${actionScope} [data-zen-notification-id="action"]`,
+    );
+    assert.doesNotMatch(
+      await actionArticle.evaluate(article => article.outerHTML),
+      /opaque-secret-token/,
+    );
+    await actionArticle.locator('.zen-notification-action').focus();
+    await page.locator(`#${actionScope}`).evaluate(scope => {
+      const source = scope.querySelector('button');
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'action-neighbor',
+          tone: 'info',
+          message: 'Neighbor',
+          announcement: 'none',
+          persistent: true,
+        },
+      }));
+    });
+    assert.equal(
+      await actionArticle.locator('.zen-notification-action').evaluate(
+        action => document.activeElement === action,
+      ),
+      true,
+    );
+    await actionArticle.locator('.zen-notification-action').click();
+    assert.deepEqual(
+      await page.evaluate(() => window.__notificationAction),
+      {
+        version: 1,
+        id: 'action',
+        token: 'opaque-secret-token',
+      },
+    );
+    await page.locator(`#${actionScope}`).evaluate(scope => {
+      const source = scope.querySelector('button');
+      source.dispatchEvent(new CustomEvent(
+        'zen-dismiss-notification',
+        {
+          bubbles: true,
+          composed: false,
+          detail: { version: 1, id: 'action' },
+        },
+      ));
+    });
+    assert.equal(await actionArticle.count(), 0);
+
+    await page.evaluate(() => {
+      const scope = document.createElement('zen-notifications');
+      scope.id = 'announcement-scope';
+      const source = document.createElement('button');
+      scope.append(source);
+      document.body.append(scope);
+      window.__announcements = [];
+      const region = scope.querySelector('[data-zen-live="polite"]');
+      new MutationObserver(() => {
+        if (region.textContent) {
+          window.__announcements.push(region.textContent);
+        }
+      }).observe(region, { childList: true });
+      for (let index = 0; index < 3; index++) {
+        source.dispatchEvent(new CustomEvent('zen-notify', {
+          bubbles: true,
+          composed: false,
+          detail: {
+            version: 1,
+            id: `announcement-${index}`,
+            tone: 'info',
+            message: `Announcement ${index}`,
+            announcement: 'polite',
+            persistent: true,
+          },
+        }));
+      }
+    });
+    await page.waitForFunction(() =>
+      window.__announcements?.length === 3
+    );
+    assert.deepEqual(
+      await page.evaluate(() => window.__announcements),
+      ['Announcement 0', 'Announcement 1', 'Announcement 2'],
+    );
+
+    const composedRejected = await page.evaluate(() => {
+      const scope = document.createElement('zen-notifications');
+      const source = document.createElement('button');
+      scope.append(source);
+      document.body.append(scope);
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: true,
+        detail: {
+          version: 1,
+          id: 'composed',
+          tone: 'info',
+          message: 'Must be rejected',
+          announcement: 'none',
+          persistent: true,
+        },
+      }));
+      return scope.size;
+    });
+    assert.equal(composedRejected, 0);
+
+    await page.evaluate(() => {
+      const scope = document.createElement('zen-notifications');
+      scope.id = 'focus-timer-scope';
+      const source = document.createElement('button');
+      scope.append(source);
+      document.body.append(scope);
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'focus-timer',
+          tone: 'warning',
+          message: 'Focus timer',
+          announcement: 'none',
+          durationMs: 300,
+          action: { token: 'focus-token', label: 'Keep focused' },
+        },
+      }));
+    });
+    const focusTimer = page.locator(
+      '#focus-timer-scope [data-zen-notification-id="focus-timer"]',
+    );
+    await focusTimer.locator('.zen-notification-action').focus();
+    await page.locator('#focus-timer-scope').evaluate(scope => {
+      const source = scope.querySelector(':scope > button');
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'focus-neighbor',
+          tone: 'info',
+          message: 'Focus neighbor',
+          announcement: 'none',
+          persistent: true,
+        },
+      }));
+    });
+    await page.waitForTimeout(500);
+    assert.equal(await focusTimer.count(), 1);
+    assert.equal(
+      await focusTimer.locator('.zen-notification-action').evaluate(
+        action => document.activeElement === action,
+      ),
+      true,
+    );
+    await page.mouse.move(0, 0);
+    await page.locator('#catalogMain').focus();
+    await page.waitForFunction(() =>
+      !document.querySelector(
+        '#focus-timer-scope [data-zen-notification-id="focus-timer"]',
+      )
+    );
+
+    await page.evaluate(() => {
+      const scope = document.createElement('zen-notifications');
+      scope.id = 'timer-scope';
+      const source = document.createElement('button');
+      scope.append(source);
+      document.body.append(scope);
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'timer',
+          tone: 'info',
+          message: 'Paused timer',
+          announcement: 'none',
+          durationMs: 300,
+        },
+      }));
+    });
+    const timerArticle = page.locator('#timer-scope article');
+    await timerArticle.hover();
+    await page.waitForTimeout(500);
+    assert.equal(await timerArticle.count(), 1);
+    await page.mouse.move(0, 0);
+    await page.waitForFunction(() =>
+      !document.querySelector('#timer-scope article')
+    );
+
+    await page.evaluate(() => {
+      const scope = document.createElement('zen-notifications');
+      scope.id = 'hidden-timer-scope';
+      const source = document.createElement('button');
+      scope.append(source);
+      document.body.append(scope);
+      source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'hidden-timer',
+          tone: 'info',
+          message: 'Hidden timer',
+          announcement: 'none',
+          durationMs: 300,
+        },
+      }));
+      Object.defineProperty(document, 'hidden', {
+        configurable: true,
+        value: true,
+      });
+      document.dispatchEvent(new Event('visibilitychange'));
+    });
+    await page.waitForTimeout(500);
+    assert.equal(
+      await page.locator('#hidden-timer-scope article').count(),
+      1,
+    );
+    await page.evaluate(() => {
+      Object.defineProperty(document, 'hidden', {
+        configurable: true,
+        value: false,
+      });
+      document.dispatchEvent(new Event('visibilitychange'));
+    });
+    await page.waitForFunction(() =>
+      !document.querySelector('#hidden-timer-scope article')
+    );
+
+    const isolated = await page.evaluate(() => {
+      const makeScope = id => {
+        const scope = document.createElement('zen-notifications');
+        scope.id = id;
+        const source = document.createElement('button');
+        scope.append(source);
+        document.body.append(scope);
+        return { scope, source };
+      };
+      const first = makeScope('scope-one');
+      const second = makeScope('scope-two');
+      first.source.dispatchEvent(new CustomEvent('zen-notify', {
+        bubbles: true,
+        composed: false,
+        detail: {
+          version: 1,
+          id: 'isolated',
+          tone: 'success',
+          message: 'Only first scope',
+          announcement: 'none',
+          persistent: true,
+        },
+      }));
+      return [first.scope.size, second.scope.size];
+    });
+    assert.deepEqual(isolated, [1, 0]);
+
     const lightCanvas = await page.evaluate(() =>
       getComputedStyle(document.documentElement)
         .getPropertyValue('--zen-color-canvas')