@unitedstatespowersquadrons/components 1.3.13 → 1.4.0-1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4,7 +4,8 @@
4
4
  "Bash(yarn run test*)",
5
5
  "Bash(yarn run vitest*)",
6
6
  "Bash(yarn run tsc*)",
7
- "Bash(yarn run eslint*)"
7
+ "Bash(yarn run eslint*)",
8
+ "Bash(git commit -m ' *)"
8
9
  ]
9
10
  }
10
11
  }
@@ -1,10 +1,9 @@
1
1
  import React from "react";
2
- import { github, shields } from "../remotes";
2
+ import { shields } from "../remotes";
3
3
  import { badgeify } from "../helpers";
4
4
  import { CheckConclusion, CheckStatus } from "../types";
5
5
 
6
6
  interface Props {
7
- repo?: string | undefined;
8
7
  label: string;
9
8
  status: CheckStatus;
10
9
  conclusion?: CheckConclusion | undefined;
@@ -12,7 +11,7 @@ interface Props {
12
11
  }
13
12
 
14
13
  const CheckBadge = (props: Props) => {
15
- const { conclusion, label, repo, skipCi, status } = props;
14
+ const { conclusion, label, skipCi, status } = props;
16
15
 
17
16
  const suiteBadgeColor = () => {
18
17
  if (skipCi) return "lightgray";
@@ -65,12 +64,7 @@ const CheckBadge = (props: Props) => {
65
64
  };
66
65
  const color = suiteBadgeColor();
67
66
 
68
- const img =
69
- <img src={`${shields}/badge/${badgeify(label)}-${description() || "Unknown"}-${color}?logo=github`} />;
70
-
71
- if (!repo || conclusion === "success") return img;
72
-
73
- return <a href={`${github}/${repo}/actions/workflows/main.yml`} rel="noreferrer" target="_blank">{img}</a>;
67
+ return <img src={`${shields}/badge/${badgeify(label)}-${description() || "Unknown"}-${color}?logo=github`} />;
74
68
  };
75
69
 
76
70
  export default CheckBadge;
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import Check from "./Check";
3
+ import { github } from "../remotes";
3
4
  import { CheckRun } from "../types";
4
5
 
5
6
  interface Props {
@@ -12,11 +13,15 @@ const RunBadge = (props: Props) => {
12
13
 
13
14
  if (!run || !run.status) return null;
14
15
 
15
- const { conclusion, name, status } = run;
16
+ const { conclusion, id, name, status } = run;
16
17
 
17
18
  const skipCi = false;
18
19
 
19
- return <Check conclusion={conclusion} label={name} repo={repo} skipCi={skipCi} status={status} />;
20
+ const badge = <Check conclusion={conclusion} label={name} skipCi={skipCi} status={status} />;
21
+
22
+ if (!repo || conclusion === "success") return badge;
23
+
24
+ return <a href={`${github}/${repo}/runs/${id}`} rel="noreferrer" target="_blank">{badge}</a>;
20
25
  };
21
26
 
22
27
  export default RunBadge;
@@ -1,5 +1,6 @@
1
1
  import React from "react";
2
2
  import Check from "./Check";
3
+ import { github } from "../remotes";
3
4
  import { CheckSuite } from "../types";
4
5
 
5
6
  interface Props {
@@ -14,7 +15,13 @@ const SuiteBadge = (props: Props) => {
14
15
 
15
16
  const { headBranch, conclusion, skipCi, status } = suite;
16
17
 
17
- return <Check conclusion={conclusion} label={headBranch} repo={repo} skipCi={skipCi} status={status} />;
18
+ const badge = <Check conclusion={conclusion} label={headBranch} skipCi={skipCi} status={status} />;
19
+
20
+ if (!repo || conclusion === "success") return badge;
21
+
22
+ return (
23
+ <a href={`${github}/${repo}/actions?query=branch:${headBranch}`} rel="noreferrer" target="_blank">{badge}</a>
24
+ );
18
25
  };
19
26
 
20
27
  export default SuiteBadge;
package/Badges/types.ts CHANGED
@@ -44,6 +44,7 @@ interface Person {
44
44
  }
45
45
 
46
46
  export interface CheckRun {
47
+ id: number;
47
48
  name: string;
48
49
  status: CheckStatus;
49
50
  conclusion?: CheckConclusion;
@@ -0,0 +1,101 @@
1
+ import React from "react";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { act, cleanup, render, screen } from "@testing-library/react";
4
+ import GlobalToasts from "./GlobalToasts";
5
+ import { toastStore } from "./globalStore";
6
+ import { Toast_Status } from "./types";
7
+
8
+ interface ReceivedHandler {
9
+ (data: { toasts?: Array<Record<string, unknown>>; dismissToast?: number }): void;
10
+ }
11
+
12
+ const subscriptionStub = {
13
+ send: vi.fn(),
14
+ unsubscribe: vi.fn(),
15
+ };
16
+ let lastReceived: ReceivedHandler | null = null;
17
+ const createSpy = vi.fn((_channel: { channel: string }, handlers: { received: ReceivedHandler }) => {
18
+ lastReceived = handlers.received;
19
+ return subscriptionStub;
20
+ });
21
+
22
+ vi.mock("@rails/actioncable", () => ({
23
+ createConsumer: () => ({
24
+ subscriptions: { create: createSpy },
25
+ }),
26
+ }));
27
+
28
+ beforeEach(() => {
29
+ toastStore.clear();
30
+ createSpy.mockClear();
31
+ subscriptionStub.send.mockClear();
32
+ subscriptionStub.unsubscribe.mockClear();
33
+ lastReceived = null;
34
+ });
35
+
36
+ afterEach(() => {
37
+ cleanup();
38
+ });
39
+
40
+ describe("<GlobalToasts />", () => {
41
+ it("subscribes exactly once to BroadcastToastChannel on mount", () => {
42
+ render(<GlobalToasts />);
43
+ expect(createSpy).toHaveBeenCalledTimes(1);
44
+ expect(createSpy.mock.calls[0]?.[0]).toEqual({ channel: "BroadcastToastChannel" });
45
+ });
46
+
47
+ it("unsubscribes on unmount", () => {
48
+ const { unmount } = render(<GlobalToasts />);
49
+ unmount();
50
+ expect(subscriptionStub.unsubscribe).toHaveBeenCalledOnce();
51
+ });
52
+
53
+ it("renders broadcasts received from the channel with broadcast styling above local toasts", () => {
54
+ toastStore.add({
55
+ body: "local body",
56
+ status: Toast_Status.SUCCESS,
57
+ timeout: 0,
58
+ title: "Local title",
59
+ });
60
+
61
+ render(<GlobalToasts />);
62
+
63
+ act(() => {
64
+ lastReceived!({
65
+ toasts: [{
66
+ body: "broadcast body",
67
+ status: Toast_Status.NOTICE,
68
+ timeout: 0,
69
+ title: "Broadcast title",
70
+ }],
71
+ });
72
+ });
73
+
74
+ const titles = screen.getAllByText(/title$/i).map(el => el.textContent);
75
+ expect(titles[0]).toBe("Broadcast title");
76
+ expect(titles[1]).toBe("Local title");
77
+ expect(screen.getByText("Broadcast")).toBeInTheDocument();
78
+ });
79
+
80
+ it("removes a toast when the channel sends dismissToast", () => {
81
+ render(<GlobalToasts />);
82
+
83
+ act(() => {
84
+ lastReceived!({
85
+ toasts: [{
86
+ body: "B",
87
+ id: 42,
88
+ status: Toast_Status.NOTICE,
89
+ timeout: 0,
90
+ title: "Going",
91
+ }],
92
+ });
93
+ });
94
+ expect(screen.getByText("Going")).toBeInTheDocument();
95
+
96
+ act(() => {
97
+ lastReceived!({ dismissToast: 42 });
98
+ });
99
+ expect(screen.queryByText("Going")).not.toBeInTheDocument();
100
+ });
101
+ });
@@ -0,0 +1,60 @@
1
+ import React, { useEffect, useSyncExternalStore } from "react";
2
+ import Toasts from "./Toasts";
3
+ import { toastStore } from "./globalStore";
4
+ import { dismissToastGlobal } from "./globalHelpers";
5
+ import { broadcastSubscriptionRef, getConsumer } from "./cableConsumer";
6
+ import { Toast, ToastProps } from "./types";
7
+
8
+ interface Props {
9
+ allowedDomains?: string[];
10
+ }
11
+
12
+ interface BroadcastPayload {
13
+ toasts?: Toast[];
14
+ dismissToast?: number;
15
+ }
16
+
17
+ const sortBroadcastsFirst = (toasts: ReadonlyArray<ToastProps>): ToastProps[] => (
18
+ [...toasts].sort((a, b) => (
19
+ Number(b.kind === "broadcast") - Number(a.kind === "broadcast")
20
+ ))
21
+ );
22
+
23
+ const GlobalToasts = ({ allowedDomains }: Props) => {
24
+ const toasts = useSyncExternalStore(toastStore.subscribe, toastStore.getSnapshot, toastStore.getSnapshot);
25
+
26
+ useEffect(() => {
27
+ const consumer = getConsumer();
28
+ const sub = consumer.subscriptions.create(
29
+ { channel: "BroadcastToastChannel" },
30
+ {
31
+ received: (data: BroadcastPayload) => {
32
+ if (data.toasts) {
33
+ data.toasts.forEach(t => toastStore.add({ ...t, kind: "broadcast" }));
34
+ }
35
+ if (typeof data.dismissToast === "number") {
36
+ toastStore.remove(data.dismissToast);
37
+ }
38
+ },
39
+ }
40
+ );
41
+ broadcastSubscriptionRef.current = sub;
42
+
43
+ return () => {
44
+ sub.unsubscribe();
45
+ if (broadcastSubscriptionRef.current === sub) {
46
+ broadcastSubscriptionRef.current = null;
47
+ }
48
+ };
49
+ }, []);
50
+
51
+ return (
52
+ <Toasts
53
+ allowedDomains={allowedDomains}
54
+ dismiss={dismissToastGlobal}
55
+ toasts={sortBroadcastsFirst(toasts)}
56
+ />
57
+ );
58
+ };
59
+
60
+ export default GlobalToasts;
package/Toasts/Toast.tsx CHANGED
@@ -22,6 +22,20 @@ const isAllowedUrl = (url: string, allowedDomains?: string[]): boolean => {
22
22
  };
23
23
 
24
24
  const useStyles = createUseStyles({
25
+ broadcast: {
26
+ borderLeft: "4px solid #7A1FA2",
27
+ },
28
+ broadcastTag: {
29
+ backgroundColor: "#7A1FA2",
30
+ borderRadius: "0.25em",
31
+ color: "#FFFFFF",
32
+ fontSize: "0.75em",
33
+ fontWeight: "bold",
34
+ letterSpacing: "0.05em",
35
+ marginRight: "0.5em",
36
+ padding: "0.1em 0.4em",
37
+ textTransform: "uppercase",
38
+ },
25
39
  dismissed: { opacity: "0" },
26
40
  error: {
27
41
  borderColor: "#CC0000",
@@ -84,7 +98,8 @@ const Toast = (props: Props) => {
84
98
  const classes = useStyles();
85
99
 
86
100
  const { allowedDomains, dismiss, toast } = props;
87
- const { body, dismissed, id, status, timeout, title } = toast;
101
+ const { body, dismissed, id, kind, status, timeout, title } = toast;
102
+ const isBroadcast = kind === "broadcast";
88
103
 
89
104
  const handleDismiss = () => dismiss(id);
90
105
 
@@ -140,9 +155,10 @@ const Toast = (props: Props) => {
140
155
  const opacityClass = dismissed ? classes.dismissed : classes.visible;
141
156
 
142
157
  return (
143
- <div className={classNames(classes.toast, statusClassName, opacityClass)} key={`toast-${id}`}>
158
+ <div className={classNames(classes.toast, statusClassName, opacityClass, isBroadcast && classes.broadcast)} key={`toast-${id}`}>
144
159
  <div className={classes.toastTitle}>
145
160
  <FontAwesomeIcon fa="fw" icon={icon} mode="duotone" />
161
+ {isBroadcast && <span className={classes.broadcastTag}>Broadcast</span>}
146
162
  <span>{title}</span>
147
163
  </div>
148
164
  <div className={classes.toastBody}>{renderBody()}</div>
package/Toasts/Toasts.tsx CHANGED
@@ -54,9 +54,13 @@ const Toasts = (props: Props) => {
54
54
  }
55
55
  };
56
56
 
57
+ const sorted = toasts && [...toasts].sort((a, b) => (
58
+ Number(b.kind === "broadcast") - Number(a.kind === "broadcast")
59
+ ));
60
+
57
61
  return (
58
62
  <div className={classNames("toastsContainer", classes.toastsContainer)}>
59
- {toasts?.map(toast => (
63
+ {sorted?.map(toast => (
60
64
  <Toast allowedDomains={allowedDomains} dismiss={dismissFunc()} key={toast.id} toast={toast} />
61
65
  ))}
62
66
  </div>
@@ -0,0 +1,10 @@
1
+ import { Consumer, createConsumer, Subscription } from "@rails/actioncable";
2
+
3
+ let consumer: Consumer | null = null;
4
+
5
+ export const getConsumer = (): Consumer => {
6
+ if (!consumer) consumer = createConsumer();
7
+ return consumer;
8
+ };
9
+
10
+ export const broadcastSubscriptionRef: { current: Subscription | null } = { current: null };
@@ -0,0 +1,51 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import { dismissToastGlobal, handleToastsGlobal, pushToast, removeToastGlobal } from "./globalHelpers";
3
+ import { toastStore } from "./globalStore";
4
+ import { Toast_Status } from "./types";
5
+
6
+ const baseToast = {
7
+ body: "Body",
8
+ status: Toast_Status.SUCCESS,
9
+ timeout: 0,
10
+ title: "Title",
11
+ };
12
+
13
+ beforeEach(() => { toastStore.clear(); });
14
+
15
+ describe("pushToast", () => {
16
+ it("delegates to the singleton and returns the id", () => {
17
+ const id = pushToast(baseToast);
18
+ expect(toastStore.getSnapshot()[0]?.id).toBe(id);
19
+ });
20
+ });
21
+
22
+ describe("handleToastsGlobal", () => {
23
+ it("adds each toast to the singleton", () => {
24
+ handleToastsGlobal([
25
+ baseToast,
26
+ { ...baseToast, body: "Two" },
27
+ ]);
28
+ expect(toastStore.getSnapshot()).toHaveLength(2);
29
+ });
30
+
31
+ it("does nothing for an empty array", () => {
32
+ handleToastsGlobal([]);
33
+ expect(toastStore.getSnapshot()).toHaveLength(0);
34
+ });
35
+ });
36
+
37
+ describe("removeToastGlobal", () => {
38
+ it("removes a toast by id", () => {
39
+ const id = pushToast(baseToast);
40
+ removeToastGlobal(id);
41
+ expect(toastStore.getSnapshot()).toHaveLength(0);
42
+ });
43
+ });
44
+
45
+ describe("dismissToastGlobal", () => {
46
+ it("marks the toast dismissed", () => {
47
+ const id = pushToast(baseToast);
48
+ dismissToastGlobal(id);
49
+ expect(toastStore.getSnapshot()[0]?.dismissed).toBe(true);
50
+ });
51
+ });
@@ -0,0 +1,12 @@
1
+ import { toastStore } from "./globalStore";
2
+ import { Toast } from "./types";
3
+
4
+ export const pushToast = (toast: Toast): number => toastStore.add(toast);
5
+
6
+ export const handleToastsGlobal = (toasts: Toast[]): void => {
7
+ toasts.forEach(t => toastStore.add(t));
8
+ };
9
+
10
+ export const dismissToastGlobal = (id: number): void => toastStore.dismiss(id);
11
+
12
+ export const removeToastGlobal = (id: number): void => toastStore.remove(id);
@@ -0,0 +1,124 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { toastStore } from "./globalStore";
3
+ import { Toast_Status } from "./types";
4
+
5
+ const baseToast = {
6
+ body: "Body",
7
+ status: Toast_Status.SUCCESS,
8
+ timeout: 0,
9
+ title: "Title",
10
+ };
11
+
12
+ beforeEach(() => {
13
+ toastStore.clear();
14
+ });
15
+
16
+ describe("toastStore.add", () => {
17
+ it("adds a toast and emits", () => {
18
+ const listener = vi.fn();
19
+ toastStore.subscribe(listener);
20
+
21
+ const id = toastStore.add(baseToast);
22
+
23
+ expect(toastStore.getSnapshot()).toHaveLength(1);
24
+ expect(toastStore.getSnapshot()[0]?.id).toBe(id);
25
+ expect(toastStore.getSnapshot()[0]?.kind).toBe("local");
26
+ expect(listener).toHaveBeenCalledOnce();
27
+ });
28
+
29
+ it("assigns auto-incrementing ids", () => {
30
+ const a = toastStore.add(baseToast);
31
+ const b = toastStore.add({ ...baseToast, body: "Other" });
32
+
33
+ expect(b).toBe(a + 1);
34
+ });
35
+
36
+ it("respects an explicit id and advances nextId past it", () => {
37
+ toastStore.add({ ...baseToast, id: 50 });
38
+ const next = toastStore.add({ ...baseToast, body: "Next" });
39
+
40
+ expect(next).toBe(51);
41
+ });
42
+
43
+ it("stores broadcast kind when supplied", () => {
44
+ toastStore.add({ ...baseToast, kind: "broadcast" });
45
+ expect(toastStore.getSnapshot()[0]?.kind).toBe("broadcast");
46
+ });
47
+
48
+ it("dedupes identical toasts (body+status+title)", () => {
49
+ const a = toastStore.add(baseToast);
50
+ const b = toastStore.add(baseToast);
51
+
52
+ expect(toastStore.getSnapshot()).toHaveLength(1);
53
+ expect(b).toBe(a);
54
+ });
55
+
56
+ it("returns a new array reference on mutation", () => {
57
+ const before = toastStore.getSnapshot();
58
+ toastStore.add(baseToast);
59
+ const after = toastStore.getSnapshot();
60
+
61
+ expect(after).not.toBe(before);
62
+ });
63
+ });
64
+
65
+ describe("toastStore.dismiss", () => {
66
+ beforeEach(() => { vi.useFakeTimers(); });
67
+ afterEach(() => { vi.useRealTimers(); });
68
+
69
+ it("flags the toast dismissed and removes it after 2000ms", () => {
70
+ const id = toastStore.add(baseToast);
71
+
72
+ toastStore.dismiss(id);
73
+ expect(toastStore.getSnapshot()[0]?.dismissed).toBe(true);
74
+ expect(toastStore.getSnapshot()).toHaveLength(1);
75
+
76
+ vi.advanceTimersByTime(2000);
77
+ expect(toastStore.getSnapshot()).toHaveLength(0);
78
+ });
79
+
80
+ it("is a no-op for unknown ids", () => {
81
+ toastStore.dismiss(999);
82
+ expect(toastStore.getSnapshot()).toHaveLength(0);
83
+ });
84
+ });
85
+
86
+ describe("toastStore.remove", () => {
87
+ it("removes a toast by id and cancels any pending dismiss timer", () => {
88
+ vi.useFakeTimers();
89
+ const listener = vi.fn();
90
+ const id = toastStore.add(baseToast);
91
+ toastStore.dismiss(id);
92
+
93
+ toastStore.subscribe(listener);
94
+ toastStore.remove(id);
95
+ expect(toastStore.getSnapshot()).toHaveLength(0);
96
+
97
+ listener.mockClear();
98
+ vi.advanceTimersByTime(2000);
99
+ expect(listener).not.toHaveBeenCalled();
100
+ vi.useRealTimers();
101
+ });
102
+
103
+ it("is a no-op for unknown ids and does not emit", () => {
104
+ const listener = vi.fn();
105
+ toastStore.subscribe(listener);
106
+
107
+ toastStore.remove(999);
108
+ expect(listener).not.toHaveBeenCalled();
109
+ });
110
+ });
111
+
112
+ describe("toastStore.subscribe", () => {
113
+ it("returns an unsubscribe function", () => {
114
+ const listener = vi.fn();
115
+ const unsubscribe = toastStore.subscribe(listener);
116
+
117
+ toastStore.add(baseToast);
118
+ expect(listener).toHaveBeenCalledOnce();
119
+
120
+ unsubscribe();
121
+ toastStore.add({ ...baseToast, body: "Two" });
122
+ expect(listener).toHaveBeenCalledOnce();
123
+ });
124
+ });
@@ -0,0 +1,86 @@
1
+ import { Toast, ToastKind, ToastProps } from "./types";
2
+
3
+ type Listener = () => void;
4
+
5
+ let toasts: ToastProps[] = [];
6
+ let nextId = 1;
7
+ const listeners = new Set<Listener>();
8
+ const pendingRemovals = new Map<number, ReturnType<typeof setTimeout>>();
9
+
10
+ const emit = () => listeners.forEach(l => l());
11
+
12
+ const isDuplicate = (toast: Toast) => toasts.some(t => (
13
+ t.body === toast.body && t.status === toast.status && t.title === toast.title
14
+ ));
15
+
16
+ const add = (toast: Toast & { kind?: ToastKind }): number => {
17
+ if (isDuplicate(toast)) {
18
+ const existing = toasts.find(t => (
19
+ t.body === toast.body && t.status === toast.status && t.title === toast.title
20
+ ));
21
+ return existing!.id;
22
+ }
23
+
24
+ const id = toast.id ?? nextId++;
25
+ if (toast.id && toast.id >= nextId) nextId = toast.id + 1;
26
+
27
+ const next: ToastProps = {
28
+ body: toast.body,
29
+ id,
30
+ kind: toast.kind ?? "local",
31
+ status: toast.status,
32
+ timeout: toast.timeout,
33
+ title: toast.title,
34
+ };
35
+
36
+ toasts = [...toasts, next];
37
+ emit();
38
+ return id;
39
+ };
40
+
41
+ const dismiss = (id: number): void => {
42
+ const idx = toasts.findIndex(t => t.id === id);
43
+ if (idx < 0) return;
44
+
45
+ toasts = toasts.map((t, i) => (i === idx ? { ...t, dismissed: true } : t));
46
+ emit();
47
+
48
+ if (pendingRemovals.has(id)) return;
49
+
50
+ const timer = setTimeout(() => {
51
+ pendingRemovals.delete(id);
52
+ remove(id);
53
+ }, 2000);
54
+ pendingRemovals.set(id, timer);
55
+ };
56
+
57
+ const remove = (id: number): void => {
58
+ const timer = pendingRemovals.get(id);
59
+ if (timer) {
60
+ clearTimeout(timer);
61
+ pendingRemovals.delete(id);
62
+ }
63
+
64
+ const next = toasts.filter(t => t.id !== id);
65
+ if (next.length === toasts.length) return;
66
+
67
+ toasts = next;
68
+ emit();
69
+ };
70
+
71
+ const clear = (): void => {
72
+ pendingRemovals.forEach(t => clearTimeout(t));
73
+ pendingRemovals.clear();
74
+ toasts = [];
75
+ nextId = 1;
76
+ emit();
77
+ };
78
+
79
+ const subscribe = (listener: Listener): (() => void) => {
80
+ listeners.add(listener);
81
+ return () => { listeners.delete(listener); };
82
+ };
83
+
84
+ const getSnapshot = (): ReadonlyArray<ToastProps> => toasts;
85
+
86
+ export const toastStore = { add, clear, dismiss, getSnapshot, remove, subscribe };
package/Toasts/index.tsx CHANGED
@@ -1,5 +1,9 @@
1
1
  export { default as Toasts } from "./Toasts";
2
+ export { default as GlobalToasts } from "./GlobalToasts";
2
3
  export * from "./toastHelpers";
4
+ export * from "./globalHelpers";
5
+ export { useBroadcastChannel } from "./useBroadcastChannel";
6
+ export type { BroadcastChannelHandle } from "./useBroadcastChannel";
3
7
 
4
8
  export * from "./types";
5
9
  export type * from "./types";
package/Toasts/types.ts CHANGED
@@ -11,6 +11,8 @@ export type ToastAppAction =
11
11
  | { type: "addToast" } & Toast
12
12
  | ToastRemoveAppAction;
13
13
 
14
+ export type ToastKind = "local" | "broadcast";
15
+
14
16
  export interface Toast {
15
17
  id?: number;
16
18
  status: Toast_Status;
@@ -18,6 +20,7 @@ export interface Toast {
18
20
  body: string;
19
21
  timeout: number;
20
22
  toastId?: number | undefined;
23
+ kind?: ToastKind;
21
24
  }
22
25
 
23
26
  export enum Toast_Status {
@@ -0,0 +1,35 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { useBroadcastChannel } from "./useBroadcastChannel";
3
+ import { broadcastSubscriptionRef } from "./cableConsumer";
4
+
5
+ describe("useBroadcastChannel", () => {
6
+ let warnSpy: ReturnType<typeof vi.spyOn>;
7
+
8
+ beforeEach(() => {
9
+ broadcastSubscriptionRef.current = null;
10
+ warnSpy = vi.spyOn(console, "warn").mockImplementation(() => undefined);
11
+ });
12
+
13
+ afterEach(() => {
14
+ broadcastSubscriptionRef.current = null;
15
+ warnSpy.mockRestore();
16
+ });
17
+
18
+ it("delegates send to the active subscription", () => {
19
+ const send = vi.fn();
20
+ broadcastSubscriptionRef.current = { send } as unknown as typeof broadcastSubscriptionRef.current;
21
+
22
+ const { send: handleSend } = useBroadcastChannel();
23
+ handleSend({ hello: "world" });
24
+
25
+ expect(send).toHaveBeenCalledWith({ hello: "world" });
26
+ expect(warnSpy).not.toHaveBeenCalled();
27
+ });
28
+
29
+ it("warns and is a no-op when no subscription is mounted", () => {
30
+ const { send } = useBroadcastChannel();
31
+ send({ hello: "world" });
32
+
33
+ expect(warnSpy).toHaveBeenCalledOnce();
34
+ });
35
+ });
@@ -0,0 +1,16 @@
1
+ import { broadcastSubscriptionRef } from "./cableConsumer";
2
+
3
+ export interface BroadcastChannelHandle {
4
+ send: (data: object) => void;
5
+ }
6
+
7
+ export const useBroadcastChannel = (): BroadcastChannelHandle => ({
8
+ send: (data: object) => {
9
+ const sub = broadcastSubscriptionRef.current;
10
+ if (!sub) {
11
+ console.warn("useBroadcastChannel: <GlobalToasts /> is not mounted; broadcast send ignored");
12
+ return;
13
+ }
14
+ sub.send(data);
15
+ },
16
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unitedstatespowersquadrons/components",
3
- "version": "1.3.13",
3
+ "version": "1.4.0-1",
4
4
  "description": "USPS shared React components library",
5
5
  "main": "index.tsx",
6
6
  "scripts": {