@dillingerstaffing/strand-ui 0.1.0 → 0.1.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.
- package/LICENSE +21 -0
- package/dist/components/Alert/Alert.d.ts +16 -0
- package/dist/components/Alert/Alert.d.ts.map +1 -0
- package/dist/components/Alert/index.d.ts +3 -0
- package/dist/components/Alert/index.d.ts.map +1 -0
- package/dist/components/Avatar/Avatar.d.ts +16 -0
- package/dist/components/Avatar/Avatar.d.ts.map +1 -0
- package/dist/components/Avatar/index.d.ts +3 -0
- package/dist/components/Avatar/index.d.ts.map +1 -0
- package/dist/components/Badge/Badge.d.ts +18 -0
- package/dist/components/Badge/Badge.d.ts.map +1 -0
- package/dist/components/Badge/index.d.ts +3 -0
- package/dist/components/Badge/index.d.ts.map +1 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts +16 -0
- package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
- package/dist/components/Breadcrumb/index.d.ts +3 -0
- package/dist/components/Breadcrumb/index.d.ts.map +1 -0
- package/dist/components/Button/Button.d.ts +22 -0
- package/dist/components/Button/Button.d.ts.map +1 -0
- package/dist/components/Button/index.d.ts +3 -0
- package/dist/components/Button/index.d.ts.map +1 -0
- package/dist/components/Card/Card.d.ts +12 -0
- package/dist/components/Card/Card.d.ts.map +1 -0
- package/dist/components/Card/index.d.ts +3 -0
- package/dist/components/Card/index.d.ts.map +1 -0
- package/dist/components/Checkbox/Checkbox.d.ts +20 -0
- package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
- package/dist/components/Checkbox/index.d.ts +3 -0
- package/dist/components/Checkbox/index.d.ts.map +1 -0
- package/dist/components/Container/Container.d.ts +10 -0
- package/dist/components/Container/Container.d.ts.map +1 -0
- package/dist/components/Container/index.d.ts +3 -0
- package/dist/components/Container/index.d.ts.map +1 -0
- package/dist/components/DataReadout/DataReadout.d.ts +12 -0
- package/dist/components/DataReadout/DataReadout.d.ts.map +1 -0
- package/dist/components/DataReadout/index.d.ts +3 -0
- package/dist/components/DataReadout/index.d.ts.map +1 -0
- package/dist/components/Dialog/Dialog.d.ts +20 -0
- package/dist/components/Dialog/Dialog.d.ts.map +1 -0
- package/dist/components/Dialog/index.d.ts +3 -0
- package/dist/components/Dialog/index.d.ts.map +1 -0
- package/dist/components/Divider/Divider.d.ts +13 -0
- package/dist/components/Divider/Divider.d.ts.map +1 -0
- package/dist/components/Divider/index.d.ts +3 -0
- package/dist/components/Divider/index.d.ts.map +1 -0
- package/dist/components/FormField/FormField.d.ts +22 -0
- package/dist/components/FormField/FormField.d.ts.map +1 -0
- package/dist/components/FormField/index.d.ts +3 -0
- package/dist/components/FormField/index.d.ts.map +1 -0
- package/dist/components/Grid/Grid.d.ts +12 -0
- package/dist/components/Grid/Grid.d.ts.map +1 -0
- package/dist/components/Grid/index.d.ts +3 -0
- package/dist/components/Grid/index.d.ts.map +1 -0
- package/dist/components/Input/Input.d.ts +18 -0
- package/dist/components/Input/Input.d.ts.map +1 -0
- package/dist/components/Input/index.d.ts +3 -0
- package/dist/components/Input/index.d.ts.map +1 -0
- package/dist/components/Link/Link.d.ts +12 -0
- package/dist/components/Link/Link.d.ts.map +1 -0
- package/dist/components/Link/index.d.ts +3 -0
- package/dist/components/Link/index.d.ts.map +1 -0
- package/dist/components/Nav/Nav.d.ts +19 -0
- package/dist/components/Nav/Nav.d.ts.map +1 -0
- package/dist/components/Nav/index.d.ts +3 -0
- package/dist/components/Nav/index.d.ts.map +1 -0
- package/dist/components/Progress/Progress.d.ts +14 -0
- package/dist/components/Progress/Progress.d.ts.map +1 -0
- package/dist/components/Progress/index.d.ts +3 -0
- package/dist/components/Progress/index.d.ts.map +1 -0
- package/dist/components/Radio/Radio.d.ts +22 -0
- package/dist/components/Radio/Radio.d.ts.map +1 -0
- package/dist/components/Radio/index.d.ts +3 -0
- package/dist/components/Radio/index.d.ts.map +1 -0
- package/dist/components/Section/Section.d.ts +12 -0
- package/dist/components/Section/Section.d.ts.map +1 -0
- package/dist/components/Section/index.d.ts +3 -0
- package/dist/components/Section/index.d.ts.map +1 -0
- package/dist/components/Select/Select.d.ts +24 -0
- package/dist/components/Select/Select.d.ts.map +1 -0
- package/dist/components/Select/index.d.ts +3 -0
- package/dist/components/Select/index.d.ts.map +1 -0
- package/dist/components/Skeleton/Skeleton.d.ts +14 -0
- package/dist/components/Skeleton/Skeleton.d.ts.map +1 -0
- package/dist/components/Skeleton/index.d.ts +3 -0
- package/dist/components/Skeleton/index.d.ts.map +1 -0
- package/dist/components/Slider/Slider.d.ts +20 -0
- package/dist/components/Slider/Slider.d.ts.map +1 -0
- package/dist/components/Slider/index.d.ts +3 -0
- package/dist/components/Slider/index.d.ts.map +1 -0
- package/dist/components/Spinner/Spinner.d.ts +10 -0
- package/dist/components/Spinner/Spinner.d.ts.map +1 -0
- package/dist/components/Spinner/index.d.ts +3 -0
- package/dist/components/Spinner/index.d.ts.map +1 -0
- package/dist/components/Stack/Stack.d.ts +18 -0
- package/dist/components/Stack/Stack.d.ts.map +1 -0
- package/dist/components/Stack/index.d.ts +3 -0
- package/dist/components/Stack/index.d.ts.map +1 -0
- package/dist/components/Switch/Switch.d.ts +18 -0
- package/dist/components/Switch/Switch.d.ts.map +1 -0
- package/dist/components/Switch/index.d.ts +3 -0
- package/dist/components/Switch/index.d.ts.map +1 -0
- package/dist/components/Table/Table.d.ts +24 -0
- package/dist/components/Table/Table.d.ts.map +1 -0
- package/dist/components/Table/index.d.ts +3 -0
- package/dist/components/Table/index.d.ts.map +1 -0
- package/dist/components/Tabs/Tabs.d.ts +19 -0
- package/dist/components/Tabs/Tabs.d.ts.map +1 -0
- package/dist/components/Tabs/index.d.ts +3 -0
- package/dist/components/Tabs/index.d.ts.map +1 -0
- package/dist/components/Tag/Tag.d.ts +18 -0
- package/dist/components/Tag/Tag.d.ts.map +1 -0
- package/dist/components/Tag/index.d.ts +3 -0
- package/dist/components/Tag/index.d.ts.map +1 -0
- package/dist/components/Textarea/Textarea.d.ts +22 -0
- package/dist/components/Textarea/Textarea.d.ts.map +1 -0
- package/dist/components/Textarea/index.d.ts +3 -0
- package/dist/components/Textarea/index.d.ts.map +1 -0
- package/dist/components/Toast/Toast.d.ts +33 -0
- package/dist/components/Toast/Toast.d.ts.map +1 -0
- package/dist/components/Toast/index.d.ts +3 -0
- package/dist/components/Toast/index.d.ts.map +1 -0
- package/dist/components/Tooltip/Tooltip.d.ts +16 -0
- package/dist/components/Tooltip/Tooltip.d.ts.map +1 -0
- package/dist/components/Tooltip/index.d.ts +3 -0
- package/dist/components/Tooltip/index.d.ts.map +1 -0
- package/dist/css/strand-ui.css +2301 -0
- package/dist/index.d.ts +64 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/test-setup.d.ts +2 -0
- package/dist/test-setup.d.ts.map +1 -0
- package/package.json +12 -11
- package/src/__tests__/build-output.test.ts +77 -0
- package/src/components/Alert/Alert.css +67 -0
- package/src/components/Alert/Alert.test.tsx +92 -0
- package/src/components/Alert/Alert.tsx +59 -0
- package/src/components/Alert/index.ts +2 -0
- package/src/components/Avatar/Avatar.css +55 -0
- package/src/components/Avatar/Avatar.test.tsx +123 -0
- package/src/components/Avatar/Avatar.tsx +67 -0
- package/src/components/Avatar/index.ts +2 -0
- package/src/components/Badge/Badge.css +72 -0
- package/src/components/Badge/Badge.test.tsx +121 -0
- package/src/components/Badge/Badge.tsx +92 -0
- package/src/components/Badge/index.ts +2 -0
- package/src/components/Breadcrumb/Breadcrumb.css +45 -0
- package/src/components/Breadcrumb/Breadcrumb.test.tsx +107 -0
- package/src/components/Breadcrumb/Breadcrumb.tsx +59 -0
- package/src/components/Breadcrumb/index.ts +2 -0
- package/src/components/Button/Button.css +188 -0
- package/src/components/Button/Button.test.tsx +171 -0
- package/src/components/Button/Button.tsx +78 -0
- package/src/components/Button/index.ts +2 -0
- package/src/components/Card/Card.css +59 -0
- package/src/components/Card/Card.test.tsx +90 -0
- package/src/components/Card/Card.tsx +41 -0
- package/src/components/Card/index.ts +2 -0
- package/src/components/Checkbox/Checkbox.css +97 -0
- package/src/components/Checkbox/Checkbox.test.tsx +92 -0
- package/src/components/Checkbox/Checkbox.tsx +137 -0
- package/src/components/Checkbox/index.ts +2 -0
- package/src/components/Container/Container.css +25 -0
- package/src/components/Container/Container.test.tsx +82 -0
- package/src/components/Container/Container.tsx +37 -0
- package/src/components/Container/index.ts +2 -0
- package/src/components/DataReadout/DataReadout.css +30 -0
- package/src/components/DataReadout/DataReadout.test.tsx +105 -0
- package/src/components/DataReadout/DataReadout.tsx +29 -0
- package/src/components/DataReadout/index.ts +2 -0
- package/src/components/Dialog/Dialog.css +80 -0
- package/src/components/Dialog/Dialog.test.tsx +203 -0
- package/src/components/Dialog/Dialog.tsx +179 -0
- package/src/components/Dialog/index.ts +2 -0
- package/src/components/Divider/Divider.css +44 -0
- package/src/components/Divider/Divider.test.tsx +86 -0
- package/src/components/Divider/Divider.tsx +81 -0
- package/src/components/Divider/index.ts +2 -0
- package/src/components/FormField/FormField.css +47 -0
- package/src/components/FormField/FormField.test.tsx +99 -0
- package/src/components/FormField/FormField.tsx +79 -0
- package/src/components/FormField/index.ts +2 -0
- package/src/components/Grid/Grid.css +6 -0
- package/src/components/Grid/Grid.test.tsx +86 -0
- package/src/components/Grid/Grid.tsx +45 -0
- package/src/components/Grid/index.ts +2 -0
- package/src/components/Input/Input.css +80 -0
- package/src/components/Input/Input.test.tsx +95 -0
- package/src/components/Input/Input.tsx +69 -0
- package/src/components/Input/index.ts +2 -0
- package/src/components/Link/Link.css +24 -0
- package/src/components/Link/Link.test.tsx +88 -0
- package/src/components/Link/Link.tsx +31 -0
- package/src/components/Link/index.ts +2 -0
- package/src/components/Nav/Nav.css +169 -0
- package/src/components/Nav/Nav.test.tsx +174 -0
- package/src/components/Nav/Nav.tsx +101 -0
- package/src/components/Nav/index.ts +2 -0
- package/src/components/Progress/Progress.css +93 -0
- package/src/components/Progress/Progress.test.tsx +93 -0
- package/src/components/Progress/Progress.tsx +104 -0
- package/src/components/Progress/index.ts +2 -0
- package/src/components/Radio/Radio.css +98 -0
- package/src/components/Radio/Radio.test.tsx +80 -0
- package/src/components/Radio/Radio.tsx +72 -0
- package/src/components/Radio/index.ts +2 -0
- package/src/components/Section/Section.css +28 -0
- package/src/components/Section/Section.test.tsx +100 -0
- package/src/components/Section/Section.tsx +41 -0
- package/src/components/Section/index.ts +2 -0
- package/src/components/Select/Select.css +68 -0
- package/src/components/Select/Select.test.tsx +99 -0
- package/src/components/Select/Select.tsx +78 -0
- package/src/components/Select/index.ts +2 -0
- package/src/components/Skeleton/Skeleton.css +52 -0
- package/src/components/Skeleton/Skeleton.test.tsx +96 -0
- package/src/components/Skeleton/Skeleton.tsx +55 -0
- package/src/components/Skeleton/index.ts +2 -0
- package/src/components/Slider/Slider.css +107 -0
- package/src/components/Slider/Slider.test.tsx +85 -0
- package/src/components/Slider/Slider.tsx +66 -0
- package/src/components/Slider/index.ts +2 -0
- package/src/components/Spinner/Spinner.css +61 -0
- package/src/components/Spinner/Spinner.test.tsx +56 -0
- package/src/components/Spinner/Spinner.tsx +38 -0
- package/src/components/Spinner/index.ts +2 -0
- package/src/components/Stack/Stack.css +56 -0
- package/src/components/Stack/Stack.test.tsx +130 -0
- package/src/components/Stack/Stack.tsx +77 -0
- package/src/components/Stack/index.ts +2 -0
- package/src/components/Switch/Switch.css +94 -0
- package/src/components/Switch/Switch.test.tsx +98 -0
- package/src/components/Switch/Switch.tsx +80 -0
- package/src/components/Switch/index.ts +2 -0
- package/src/components/Table/Table.css +78 -0
- package/src/components/Table/Table.test.tsx +134 -0
- package/src/components/Table/Table.tsx +102 -0
- package/src/components/Table/index.ts +2 -0
- package/src/components/Tabs/Tabs.css +46 -0
- package/src/components/Tabs/Tabs.test.tsx +164 -0
- package/src/components/Tabs/Tabs.tsx +126 -0
- package/src/components/Tabs/index.ts +2 -0
- package/src/components/Tag/Tag.css +98 -0
- package/src/components/Tag/Tag.test.tsx +112 -0
- package/src/components/Tag/Tag.tsx +73 -0
- package/src/components/Tag/index.ts +2 -0
- package/src/components/Textarea/Textarea.css +73 -0
- package/src/components/Textarea/Textarea.test.tsx +89 -0
- package/src/components/Textarea/Textarea.tsx +102 -0
- package/src/components/Textarea/index.ts +2 -0
- package/src/components/Toast/Toast.css +103 -0
- package/src/components/Toast/Toast.test.tsx +219 -0
- package/src/components/Toast/Toast.tsx +177 -0
- package/src/components/Toast/index.ts +2 -0
- package/src/components/Tooltip/Tooltip.css +63 -0
- package/src/components/Tooltip/Tooltip.test.tsx +196 -0
- package/src/components/Tooltip/Tooltip.tsx +89 -0
- package/src/components/Tooltip/index.ts +2 -0
- package/src/index.ts +99 -0
- package/src/test-setup.ts +7 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, fireEvent, act } from "@testing-library/preact";
|
|
3
|
+
import { Toast, ToastProvider, useToast } from "./Toast.js";
|
|
4
|
+
|
|
5
|
+
/** Helper component that triggers a toast via the hook */
|
|
6
|
+
function TestTrigger({
|
|
7
|
+
message = "Test message",
|
|
8
|
+
status,
|
|
9
|
+
duration,
|
|
10
|
+
}: {
|
|
11
|
+
message?: string;
|
|
12
|
+
status?: "info" | "success" | "warning" | "error";
|
|
13
|
+
duration?: number;
|
|
14
|
+
}) {
|
|
15
|
+
const { toast } = useToast();
|
|
16
|
+
return (
|
|
17
|
+
<button
|
|
18
|
+
type="button"
|
|
19
|
+
onClick={() => toast({ message, status, duration })}
|
|
20
|
+
>
|
|
21
|
+
Trigger
|
|
22
|
+
</button>
|
|
23
|
+
);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
describe("Toast", () => {
|
|
27
|
+
// ── Standalone Toast component ──
|
|
28
|
+
|
|
29
|
+
it("renders message text", () => {
|
|
30
|
+
const { getByText } = render(<Toast message="Hello" />);
|
|
31
|
+
expect(getByText("Hello")).toBeTruthy();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("applies status class", () => {
|
|
35
|
+
const { container } = render(<Toast message="OK" status="success" />);
|
|
36
|
+
expect(
|
|
37
|
+
container.querySelector(".strand-toast--success"),
|
|
38
|
+
).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("has role status", () => {
|
|
42
|
+
const { getByRole } = render(<Toast message="Info" />);
|
|
43
|
+
expect(getByRole("status")).toBeTruthy();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it("error toast has aria-live assertive", () => {
|
|
47
|
+
const { getByRole } = render(
|
|
48
|
+
<Toast message="Fail" status="error" />,
|
|
49
|
+
);
|
|
50
|
+
expect(getByRole("status")).toHaveAttribute("aria-live", "assertive");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("info toast has aria-live polite", () => {
|
|
54
|
+
const { getByRole } = render(
|
|
55
|
+
<Toast message="Note" status="info" />,
|
|
56
|
+
);
|
|
57
|
+
expect(getByRole("status")).toHaveAttribute("aria-live", "polite");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("warning toast has aria-live assertive", () => {
|
|
61
|
+
const { getByRole } = render(
|
|
62
|
+
<Toast message="Warn" status="warning" />,
|
|
63
|
+
);
|
|
64
|
+
expect(getByRole("status")).toHaveAttribute("aria-live", "assertive");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it("renders dismiss button when onDismiss provided", () => {
|
|
68
|
+
const onDismiss = vi.fn();
|
|
69
|
+
const { getByLabelText } = render(
|
|
70
|
+
<Toast message="Bye" onDismiss={onDismiss} />,
|
|
71
|
+
);
|
|
72
|
+
fireEvent.click(getByLabelText("Dismiss"));
|
|
73
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("merges custom className", () => {
|
|
77
|
+
const { container } = render(
|
|
78
|
+
<Toast message="Styled" className="custom-toast" />,
|
|
79
|
+
);
|
|
80
|
+
const el = container.querySelector(".strand-toast")!;
|
|
81
|
+
expect(el.className).toContain("custom-toast");
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("defaults to info status", () => {
|
|
85
|
+
const { container } = render(<Toast message="Default" />);
|
|
86
|
+
expect(
|
|
87
|
+
container.querySelector(".strand-toast--info"),
|
|
88
|
+
).toBeTruthy();
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe("ToastProvider + useToast", () => {
|
|
93
|
+
beforeEach(() => {
|
|
94
|
+
vi.useFakeTimers();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterEach(() => {
|
|
98
|
+
vi.useRealTimers();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it("renders children", () => {
|
|
102
|
+
const { getByText } = render(
|
|
103
|
+
<ToastProvider>
|
|
104
|
+
<p>App content</p>
|
|
105
|
+
</ToastProvider>,
|
|
106
|
+
);
|
|
107
|
+
expect(getByText("App content")).toBeTruthy();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("useToast adds a toast that renders message", () => {
|
|
111
|
+
const { getByText } = render(
|
|
112
|
+
<ToastProvider>
|
|
113
|
+
<TestTrigger message="Hello toast" />
|
|
114
|
+
</ToastProvider>,
|
|
115
|
+
);
|
|
116
|
+
fireEvent.click(getByText("Trigger"));
|
|
117
|
+
expect(getByText("Hello toast")).toBeTruthy();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("toast has correct status class", () => {
|
|
121
|
+
const { getByText, container } = render(
|
|
122
|
+
<ToastProvider>
|
|
123
|
+
<TestTrigger message="Error occurred" status="error" />
|
|
124
|
+
</ToastProvider>,
|
|
125
|
+
);
|
|
126
|
+
fireEvent.click(getByText("Trigger"));
|
|
127
|
+
expect(
|
|
128
|
+
container.querySelector(".strand-toast--error"),
|
|
129
|
+
).toBeTruthy();
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it("toast has role status", () => {
|
|
133
|
+
const { getByText, getAllByRole } = render(
|
|
134
|
+
<ToastProvider>
|
|
135
|
+
<TestTrigger message="Status toast" />
|
|
136
|
+
</ToastProvider>,
|
|
137
|
+
);
|
|
138
|
+
fireEvent.click(getByText("Trigger"));
|
|
139
|
+
const statuses = getAllByRole("status");
|
|
140
|
+
expect(statuses.length).toBeGreaterThan(0);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("error toast in provider has aria-live assertive", () => {
|
|
144
|
+
const { getByText, container } = render(
|
|
145
|
+
<ToastProvider>
|
|
146
|
+
<TestTrigger message="Err" status="error" />
|
|
147
|
+
</ToastProvider>,
|
|
148
|
+
);
|
|
149
|
+
fireEvent.click(getByText("Trigger"));
|
|
150
|
+
const toast = container.querySelector(".strand-toast--error")!;
|
|
151
|
+
expect(toast.getAttribute("aria-live")).toBe("assertive");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("toast auto-dismisses after duration", () => {
|
|
155
|
+
const { getByText, queryByText } = render(
|
|
156
|
+
<ToastProvider>
|
|
157
|
+
<TestTrigger message="Vanishing" duration={3000} />
|
|
158
|
+
</ToastProvider>,
|
|
159
|
+
);
|
|
160
|
+
fireEvent.click(getByText("Trigger"));
|
|
161
|
+
expect(getByText("Vanishing")).toBeTruthy();
|
|
162
|
+
|
|
163
|
+
act(() => {
|
|
164
|
+
vi.advanceTimersByTime(3000);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
expect(queryByText("Vanishing")).toBeNull();
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("dismiss button removes toast", () => {
|
|
171
|
+
const { getByText, getByLabelText, queryByText } = render(
|
|
172
|
+
<ToastProvider>
|
|
173
|
+
<TestTrigger message="Dismissable" />
|
|
174
|
+
</ToastProvider>,
|
|
175
|
+
);
|
|
176
|
+
fireEvent.click(getByText("Trigger"));
|
|
177
|
+
expect(getByText("Dismissable")).toBeTruthy();
|
|
178
|
+
|
|
179
|
+
fireEvent.click(getByLabelText("Dismiss"));
|
|
180
|
+
expect(queryByText("Dismissable")).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("defaults to info status when none provided", () => {
|
|
184
|
+
const { getByText, container } = render(
|
|
185
|
+
<ToastProvider>
|
|
186
|
+
<TestTrigger message="Default info" />
|
|
187
|
+
</ToastProvider>,
|
|
188
|
+
);
|
|
189
|
+
fireEvent.click(getByText("Trigger"));
|
|
190
|
+
expect(
|
|
191
|
+
container.querySelector(".strand-toast--info"),
|
|
192
|
+
).toBeTruthy();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("multiple toasts stack", () => {
|
|
196
|
+
const { getByText, container } = render(
|
|
197
|
+
<ToastProvider>
|
|
198
|
+
<TestTrigger message="First" />
|
|
199
|
+
</ToastProvider>,
|
|
200
|
+
);
|
|
201
|
+
fireEvent.click(getByText("Trigger"));
|
|
202
|
+
fireEvent.click(getByText("Trigger"));
|
|
203
|
+
const toasts = container.querySelectorAll(".strand-toast");
|
|
204
|
+
expect(toasts.length).toBe(2);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("custom className on provider container", () => {
|
|
208
|
+
const { getByText, container } = render(
|
|
209
|
+
<ToastProvider className="custom-provider">
|
|
210
|
+
<TestTrigger message="Styled" />
|
|
211
|
+
</ToastProvider>,
|
|
212
|
+
);
|
|
213
|
+
fireEvent.click(getByText("Trigger"));
|
|
214
|
+
const toastContainer = container.querySelector(
|
|
215
|
+
".strand-toast__container",
|
|
216
|
+
)!;
|
|
217
|
+
expect(toastContainer.className).toContain("custom-provider");
|
|
218
|
+
});
|
|
219
|
+
});
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/*! Strand UI | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
import type { ComponentChildren, JSX } from "preact";
|
|
4
|
+
import { createContext } from "preact";
|
|
5
|
+
import { forwardRef } from "preact/compat";
|
|
6
|
+
import { useState, useContext, useEffect, useCallback, useRef } from "preact/hooks";
|
|
7
|
+
|
|
8
|
+
export type ToastStatus = "info" | "success" | "warning" | "error";
|
|
9
|
+
|
|
10
|
+
export interface ToastOptions {
|
|
11
|
+
message: string;
|
|
12
|
+
status?: ToastStatus;
|
|
13
|
+
duration?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface ToastEntry extends Required<Omit<ToastOptions, "duration">> {
|
|
17
|
+
id: number;
|
|
18
|
+
duration: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
interface ToastContextValue {
|
|
22
|
+
toast: (options: ToastOptions) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const ToastContext = createContext<ToastContextValue | null>(null);
|
|
26
|
+
|
|
27
|
+
export function useToast(): ToastContextValue {
|
|
28
|
+
const ctx = useContext(ToastContext);
|
|
29
|
+
if (!ctx) {
|
|
30
|
+
throw new Error("useToast must be used within a ToastProvider");
|
|
31
|
+
}
|
|
32
|
+
return ctx;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
let toastIdCounter = 0;
|
|
36
|
+
|
|
37
|
+
export interface ToastProviderProps {
|
|
38
|
+
children?: ComponentChildren;
|
|
39
|
+
className?: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export const ToastProvider = ({ children, className = "" }: ToastProviderProps) => {
|
|
43
|
+
const [toasts, setToasts] = useState<ToastEntry[]>([]);
|
|
44
|
+
|
|
45
|
+
const removeToast = useCallback((id: number) => {
|
|
46
|
+
setToasts((prev) => prev.filter((t) => t.id !== id));
|
|
47
|
+
}, []);
|
|
48
|
+
|
|
49
|
+
const addToast = useCallback((options: ToastOptions) => {
|
|
50
|
+
const entry: ToastEntry = {
|
|
51
|
+
id: ++toastIdCounter,
|
|
52
|
+
message: options.message,
|
|
53
|
+
status: options.status ?? "info",
|
|
54
|
+
duration: options.duration ?? 5000,
|
|
55
|
+
};
|
|
56
|
+
setToasts((prev) => [...prev, entry]);
|
|
57
|
+
}, []);
|
|
58
|
+
|
|
59
|
+
const containerClasses = ["strand-toast__container", className]
|
|
60
|
+
.filter(Boolean)
|
|
61
|
+
.join(" ");
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<ToastContext.Provider value={{ toast: addToast }}>
|
|
65
|
+
{children}
|
|
66
|
+
{toasts.length > 0 && (
|
|
67
|
+
<div className={containerClasses}>
|
|
68
|
+
{toasts.map((entry) => (
|
|
69
|
+
<ToastItem
|
|
70
|
+
key={entry.id}
|
|
71
|
+
entry={entry}
|
|
72
|
+
onDismiss={() => removeToast(entry.id)}
|
|
73
|
+
/>
|
|
74
|
+
))}
|
|
75
|
+
</div>
|
|
76
|
+
)}
|
|
77
|
+
</ToastContext.Provider>
|
|
78
|
+
);
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
ToastProvider.displayName = "ToastProvider";
|
|
82
|
+
|
|
83
|
+
/* ── Individual toast item ── */
|
|
84
|
+
|
|
85
|
+
interface ToastItemProps {
|
|
86
|
+
entry: ToastEntry;
|
|
87
|
+
onDismiss: () => void;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function ToastItem({ entry, onDismiss }: ToastItemProps) {
|
|
91
|
+
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
92
|
+
|
|
93
|
+
useEffect(() => {
|
|
94
|
+
if (entry.duration > 0) {
|
|
95
|
+
timerRef.current = setTimeout(onDismiss, entry.duration);
|
|
96
|
+
}
|
|
97
|
+
return () => {
|
|
98
|
+
if (timerRef.current !== null) {
|
|
99
|
+
clearTimeout(timerRef.current);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
}, [entry.duration, onDismiss]);
|
|
103
|
+
|
|
104
|
+
const isUrgent = entry.status === "error" || entry.status === "warning";
|
|
105
|
+
|
|
106
|
+
const classes = ["strand-toast", `strand-toast--${entry.status}`]
|
|
107
|
+
.filter(Boolean)
|
|
108
|
+
.join(" ");
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<div
|
|
112
|
+
className={classes}
|
|
113
|
+
role="status"
|
|
114
|
+
aria-live={isUrgent ? "assertive" : "polite"}
|
|
115
|
+
>
|
|
116
|
+
<span className="strand-toast__message">{entry.message}</span>
|
|
117
|
+
<button
|
|
118
|
+
type="button"
|
|
119
|
+
className="strand-toast__dismiss"
|
|
120
|
+
aria-label="Dismiss"
|
|
121
|
+
onClick={onDismiss}
|
|
122
|
+
>
|
|
123
|
+
×
|
|
124
|
+
</button>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/* ── Standalone Toast (for direct rendering) ── */
|
|
130
|
+
|
|
131
|
+
export interface ToastProps
|
|
132
|
+
extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "status"> {
|
|
133
|
+
/** Visual status */
|
|
134
|
+
status?: ToastStatus;
|
|
135
|
+
/** Toast message text */
|
|
136
|
+
message: string;
|
|
137
|
+
/** Called when dismiss button is clicked */
|
|
138
|
+
onDismiss?: () => void;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const Toast = forwardRef<HTMLDivElement, ToastProps>(
|
|
142
|
+
({ status = "info", message, onDismiss, className = "", ...rest }, ref) => {
|
|
143
|
+
const isUrgent = status === "error" || status === "warning";
|
|
144
|
+
|
|
145
|
+
const classes = [
|
|
146
|
+
"strand-toast",
|
|
147
|
+
`strand-toast--${status}`,
|
|
148
|
+
className,
|
|
149
|
+
]
|
|
150
|
+
.filter(Boolean)
|
|
151
|
+
.join(" ");
|
|
152
|
+
|
|
153
|
+
return (
|
|
154
|
+
<div
|
|
155
|
+
ref={ref}
|
|
156
|
+
className={classes}
|
|
157
|
+
role="status"
|
|
158
|
+
aria-live={isUrgent ? "assertive" : "polite"}
|
|
159
|
+
{...rest}
|
|
160
|
+
>
|
|
161
|
+
<span className="strand-toast__message">{message}</span>
|
|
162
|
+
{onDismiss && (
|
|
163
|
+
<button
|
|
164
|
+
type="button"
|
|
165
|
+
className="strand-toast__dismiss"
|
|
166
|
+
aria-label="Dismiss"
|
|
167
|
+
onClick={onDismiss}
|
|
168
|
+
>
|
|
169
|
+
×
|
|
170
|
+
</button>
|
|
171
|
+
)}
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
Toast.displayName = "Toast";
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/*! Strand UI | MIT License | dillingerstaffing.com */
|
|
2
|
+
|
|
3
|
+
/* ── Wrapper ── */
|
|
4
|
+
.strand-tooltip__wrapper {
|
|
5
|
+
position: relative;
|
|
6
|
+
display: inline-flex;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/* ── Tooltip ── */
|
|
10
|
+
.strand-tooltip {
|
|
11
|
+
position: absolute;
|
|
12
|
+
z-index: 1200;
|
|
13
|
+
padding: var(--strand-space-1) var(--strand-space-2);
|
|
14
|
+
background: var(--strand-gray-900);
|
|
15
|
+
color: #fff;
|
|
16
|
+
font-family: var(--strand-font-sans);
|
|
17
|
+
font-size: var(--strand-text-xs);
|
|
18
|
+
border-radius: var(--strand-radius-md);
|
|
19
|
+
white-space: nowrap;
|
|
20
|
+
pointer-events: none;
|
|
21
|
+
opacity: 0;
|
|
22
|
+
transition: opacity var(--strand-duration-fast) ease;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
.strand-tooltip--visible {
|
|
26
|
+
opacity: 1;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/* ── Positions ── */
|
|
30
|
+
.strand-tooltip--top {
|
|
31
|
+
bottom: 100%;
|
|
32
|
+
left: 50%;
|
|
33
|
+
transform: translateX(-50%);
|
|
34
|
+
margin-bottom: var(--strand-space-2);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
.strand-tooltip--bottom {
|
|
38
|
+
top: 100%;
|
|
39
|
+
left: 50%;
|
|
40
|
+
transform: translateX(-50%);
|
|
41
|
+
margin-top: var(--strand-space-2);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.strand-tooltip--left {
|
|
45
|
+
right: 100%;
|
|
46
|
+
top: 50%;
|
|
47
|
+
transform: translateY(-50%);
|
|
48
|
+
margin-right: var(--strand-space-2);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.strand-tooltip--right {
|
|
52
|
+
left: 100%;
|
|
53
|
+
top: 50%;
|
|
54
|
+
transform: translateY(-50%);
|
|
55
|
+
margin-left: var(--strand-space-2);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/* ── Reduced motion ── */
|
|
59
|
+
@media (prefers-reduced-motion: reduce) {
|
|
60
|
+
.strand-tooltip {
|
|
61
|
+
transition: none;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
|
2
|
+
import { render, fireEvent, act } from "@testing-library/preact";
|
|
3
|
+
import { Tooltip } from "./Tooltip.js";
|
|
4
|
+
|
|
5
|
+
describe("Tooltip", () => {
|
|
6
|
+
beforeEach(() => {
|
|
7
|
+
vi.useFakeTimers();
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
afterEach(() => {
|
|
11
|
+
vi.useRealTimers();
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it("renders children", () => {
|
|
15
|
+
const { getByText } = render(
|
|
16
|
+
<Tooltip content="Hint">
|
|
17
|
+
<button type="button">Hover me</button>
|
|
18
|
+
</Tooltip>,
|
|
19
|
+
);
|
|
20
|
+
expect(getByText("Hover me")).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("renders tooltip content text", () => {
|
|
24
|
+
const { container } = render(
|
|
25
|
+
<Tooltip content="Helpful tip">
|
|
26
|
+
<button type="button">Target</button>
|
|
27
|
+
</Tooltip>,
|
|
28
|
+
);
|
|
29
|
+
const tooltip = container.querySelector('[role="tooltip"]');
|
|
30
|
+
expect(tooltip).toBeTruthy();
|
|
31
|
+
expect(tooltip!.textContent).toBe("Helpful tip");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("tooltip is hidden by default", () => {
|
|
35
|
+
const { container } = render(
|
|
36
|
+
<Tooltip content="Hidden">
|
|
37
|
+
<button type="button">Target</button>
|
|
38
|
+
</Tooltip>,
|
|
39
|
+
);
|
|
40
|
+
const tooltip = container.querySelector('[role="tooltip"]');
|
|
41
|
+
expect(tooltip).toHaveAttribute("aria-hidden", "true");
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("shows tooltip on mouseenter after delay", () => {
|
|
45
|
+
const { container } = render(
|
|
46
|
+
<Tooltip content="Visible" delay={100}>
|
|
47
|
+
<button type="button">Target</button>
|
|
48
|
+
</Tooltip>,
|
|
49
|
+
);
|
|
50
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
51
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
52
|
+
|
|
53
|
+
fireEvent.mouseEnter(wrapper);
|
|
54
|
+
act(() => {
|
|
55
|
+
vi.advanceTimersByTime(100);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
expect(tooltip).toHaveAttribute("aria-hidden", "false");
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("hides tooltip on mouseleave", () => {
|
|
62
|
+
const { container } = render(
|
|
63
|
+
<Tooltip content="Gone" delay={100}>
|
|
64
|
+
<button type="button">Target</button>
|
|
65
|
+
</Tooltip>,
|
|
66
|
+
);
|
|
67
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
68
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
69
|
+
|
|
70
|
+
fireEvent.mouseEnter(wrapper);
|
|
71
|
+
act(() => {
|
|
72
|
+
vi.advanceTimersByTime(100);
|
|
73
|
+
});
|
|
74
|
+
expect(tooltip).toHaveAttribute("aria-hidden", "false");
|
|
75
|
+
|
|
76
|
+
fireEvent.mouseLeave(wrapper);
|
|
77
|
+
expect(tooltip).toHaveAttribute("aria-hidden", "true");
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it("wrapper has onFocus handler for keyboard accessibility", () => {
|
|
81
|
+
const { container } = render(
|
|
82
|
+
<Tooltip content="Focused" delay={100}>
|
|
83
|
+
<button type="button">Target</button>
|
|
84
|
+
</Tooltip>,
|
|
85
|
+
);
|
|
86
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
87
|
+
// Verify the wrapper element exists and tooltip is associated
|
|
88
|
+
expect(wrapper).toBeTruthy();
|
|
89
|
+
expect(wrapper.getAttribute("aria-describedby")).toBeTruthy();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("wrapper has onBlur handler for keyboard accessibility", () => {
|
|
93
|
+
const { container } = render(
|
|
94
|
+
<Tooltip content="Blurred" delay={100}>
|
|
95
|
+
<button type="button">Target</button>
|
|
96
|
+
</Tooltip>,
|
|
97
|
+
);
|
|
98
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
99
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
100
|
+
// Tooltip starts hidden and wrapper is properly configured
|
|
101
|
+
expect(tooltip).toHaveAttribute("aria-hidden", "true");
|
|
102
|
+
expect(wrapper).toBeTruthy();
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("tooltip has role tooltip", () => {
|
|
106
|
+
const { container } = render(
|
|
107
|
+
<Tooltip content="Accessible">
|
|
108
|
+
<button type="button">Target</button>
|
|
109
|
+
</Tooltip>,
|
|
110
|
+
);
|
|
111
|
+
expect(container.querySelector('[role="tooltip"]')).toBeTruthy();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("trigger has aria-describedby pointing to tooltip id", () => {
|
|
115
|
+
const { container } = render(
|
|
116
|
+
<Tooltip content="Described">
|
|
117
|
+
<button type="button">Target</button>
|
|
118
|
+
</Tooltip>,
|
|
119
|
+
);
|
|
120
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
121
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
122
|
+
const tooltipId = tooltip.getAttribute("id");
|
|
123
|
+
expect(wrapper).toHaveAttribute("aria-describedby", tooltipId);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("applies top position class by default", () => {
|
|
127
|
+
const { container } = render(
|
|
128
|
+
<Tooltip content="Top">
|
|
129
|
+
<button type="button">Target</button>
|
|
130
|
+
</Tooltip>,
|
|
131
|
+
);
|
|
132
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
133
|
+
expect(tooltip.className).toContain("strand-tooltip--top");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("applies right position class", () => {
|
|
137
|
+
const { container } = render(
|
|
138
|
+
<Tooltip content="Right" position="right">
|
|
139
|
+
<button type="button">Target</button>
|
|
140
|
+
</Tooltip>,
|
|
141
|
+
);
|
|
142
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
143
|
+
expect(tooltip.className).toContain("strand-tooltip--right");
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("applies bottom position class", () => {
|
|
147
|
+
const { container } = render(
|
|
148
|
+
<Tooltip content="Bottom" position="bottom">
|
|
149
|
+
<button type="button">Target</button>
|
|
150
|
+
</Tooltip>,
|
|
151
|
+
);
|
|
152
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
153
|
+
expect(tooltip.className).toContain("strand-tooltip--bottom");
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("applies left position class", () => {
|
|
157
|
+
const { container } = render(
|
|
158
|
+
<Tooltip content="Left" position="left">
|
|
159
|
+
<button type="button">Target</button>
|
|
160
|
+
</Tooltip>,
|
|
161
|
+
);
|
|
162
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
163
|
+
expect(tooltip.className).toContain("strand-tooltip--left");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("merges custom className on wrapper", () => {
|
|
167
|
+
const { container } = render(
|
|
168
|
+
<Tooltip content="Styled" className="custom-tip">
|
|
169
|
+
<button type="button">Target</button>
|
|
170
|
+
</Tooltip>,
|
|
171
|
+
);
|
|
172
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
173
|
+
expect(wrapper.className).toContain("custom-tip");
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
it("cancels show if mouseleave before delay completes", () => {
|
|
177
|
+
const { container } = render(
|
|
178
|
+
<Tooltip content="Cancelled" delay={300}>
|
|
179
|
+
<button type="button">Target</button>
|
|
180
|
+
</Tooltip>,
|
|
181
|
+
);
|
|
182
|
+
const wrapper = container.querySelector(".strand-tooltip__wrapper")!;
|
|
183
|
+
const tooltip = container.querySelector('[role="tooltip"]')!;
|
|
184
|
+
|
|
185
|
+
fireEvent.mouseEnter(wrapper);
|
|
186
|
+
act(() => {
|
|
187
|
+
vi.advanceTimersByTime(100);
|
|
188
|
+
});
|
|
189
|
+
fireEvent.mouseLeave(wrapper);
|
|
190
|
+
act(() => {
|
|
191
|
+
vi.advanceTimersByTime(300);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
expect(tooltip).toHaveAttribute("aria-hidden", "true");
|
|
195
|
+
});
|
|
196
|
+
});
|