@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.
Files changed (258) hide show
  1. package/LICENSE +21 -0
  2. package/dist/components/Alert/Alert.d.ts +16 -0
  3. package/dist/components/Alert/Alert.d.ts.map +1 -0
  4. package/dist/components/Alert/index.d.ts +3 -0
  5. package/dist/components/Alert/index.d.ts.map +1 -0
  6. package/dist/components/Avatar/Avatar.d.ts +16 -0
  7. package/dist/components/Avatar/Avatar.d.ts.map +1 -0
  8. package/dist/components/Avatar/index.d.ts +3 -0
  9. package/dist/components/Avatar/index.d.ts.map +1 -0
  10. package/dist/components/Badge/Badge.d.ts +18 -0
  11. package/dist/components/Badge/Badge.d.ts.map +1 -0
  12. package/dist/components/Badge/index.d.ts +3 -0
  13. package/dist/components/Badge/index.d.ts.map +1 -0
  14. package/dist/components/Breadcrumb/Breadcrumb.d.ts +16 -0
  15. package/dist/components/Breadcrumb/Breadcrumb.d.ts.map +1 -0
  16. package/dist/components/Breadcrumb/index.d.ts +3 -0
  17. package/dist/components/Breadcrumb/index.d.ts.map +1 -0
  18. package/dist/components/Button/Button.d.ts +22 -0
  19. package/dist/components/Button/Button.d.ts.map +1 -0
  20. package/dist/components/Button/index.d.ts +3 -0
  21. package/dist/components/Button/index.d.ts.map +1 -0
  22. package/dist/components/Card/Card.d.ts +12 -0
  23. package/dist/components/Card/Card.d.ts.map +1 -0
  24. package/dist/components/Card/index.d.ts +3 -0
  25. package/dist/components/Card/index.d.ts.map +1 -0
  26. package/dist/components/Checkbox/Checkbox.d.ts +20 -0
  27. package/dist/components/Checkbox/Checkbox.d.ts.map +1 -0
  28. package/dist/components/Checkbox/index.d.ts +3 -0
  29. package/dist/components/Checkbox/index.d.ts.map +1 -0
  30. package/dist/components/Container/Container.d.ts +10 -0
  31. package/dist/components/Container/Container.d.ts.map +1 -0
  32. package/dist/components/Container/index.d.ts +3 -0
  33. package/dist/components/Container/index.d.ts.map +1 -0
  34. package/dist/components/DataReadout/DataReadout.d.ts +12 -0
  35. package/dist/components/DataReadout/DataReadout.d.ts.map +1 -0
  36. package/dist/components/DataReadout/index.d.ts +3 -0
  37. package/dist/components/DataReadout/index.d.ts.map +1 -0
  38. package/dist/components/Dialog/Dialog.d.ts +20 -0
  39. package/dist/components/Dialog/Dialog.d.ts.map +1 -0
  40. package/dist/components/Dialog/index.d.ts +3 -0
  41. package/dist/components/Dialog/index.d.ts.map +1 -0
  42. package/dist/components/Divider/Divider.d.ts +13 -0
  43. package/dist/components/Divider/Divider.d.ts.map +1 -0
  44. package/dist/components/Divider/index.d.ts +3 -0
  45. package/dist/components/Divider/index.d.ts.map +1 -0
  46. package/dist/components/FormField/FormField.d.ts +22 -0
  47. package/dist/components/FormField/FormField.d.ts.map +1 -0
  48. package/dist/components/FormField/index.d.ts +3 -0
  49. package/dist/components/FormField/index.d.ts.map +1 -0
  50. package/dist/components/Grid/Grid.d.ts +12 -0
  51. package/dist/components/Grid/Grid.d.ts.map +1 -0
  52. package/dist/components/Grid/index.d.ts +3 -0
  53. package/dist/components/Grid/index.d.ts.map +1 -0
  54. package/dist/components/Input/Input.d.ts +18 -0
  55. package/dist/components/Input/Input.d.ts.map +1 -0
  56. package/dist/components/Input/index.d.ts +3 -0
  57. package/dist/components/Input/index.d.ts.map +1 -0
  58. package/dist/components/Link/Link.d.ts +12 -0
  59. package/dist/components/Link/Link.d.ts.map +1 -0
  60. package/dist/components/Link/index.d.ts +3 -0
  61. package/dist/components/Link/index.d.ts.map +1 -0
  62. package/dist/components/Nav/Nav.d.ts +19 -0
  63. package/dist/components/Nav/Nav.d.ts.map +1 -0
  64. package/dist/components/Nav/index.d.ts +3 -0
  65. package/dist/components/Nav/index.d.ts.map +1 -0
  66. package/dist/components/Progress/Progress.d.ts +14 -0
  67. package/dist/components/Progress/Progress.d.ts.map +1 -0
  68. package/dist/components/Progress/index.d.ts +3 -0
  69. package/dist/components/Progress/index.d.ts.map +1 -0
  70. package/dist/components/Radio/Radio.d.ts +22 -0
  71. package/dist/components/Radio/Radio.d.ts.map +1 -0
  72. package/dist/components/Radio/index.d.ts +3 -0
  73. package/dist/components/Radio/index.d.ts.map +1 -0
  74. package/dist/components/Section/Section.d.ts +12 -0
  75. package/dist/components/Section/Section.d.ts.map +1 -0
  76. package/dist/components/Section/index.d.ts +3 -0
  77. package/dist/components/Section/index.d.ts.map +1 -0
  78. package/dist/components/Select/Select.d.ts +24 -0
  79. package/dist/components/Select/Select.d.ts.map +1 -0
  80. package/dist/components/Select/index.d.ts +3 -0
  81. package/dist/components/Select/index.d.ts.map +1 -0
  82. package/dist/components/Skeleton/Skeleton.d.ts +14 -0
  83. package/dist/components/Skeleton/Skeleton.d.ts.map +1 -0
  84. package/dist/components/Skeleton/index.d.ts +3 -0
  85. package/dist/components/Skeleton/index.d.ts.map +1 -0
  86. package/dist/components/Slider/Slider.d.ts +20 -0
  87. package/dist/components/Slider/Slider.d.ts.map +1 -0
  88. package/dist/components/Slider/index.d.ts +3 -0
  89. package/dist/components/Slider/index.d.ts.map +1 -0
  90. package/dist/components/Spinner/Spinner.d.ts +10 -0
  91. package/dist/components/Spinner/Spinner.d.ts.map +1 -0
  92. package/dist/components/Spinner/index.d.ts +3 -0
  93. package/dist/components/Spinner/index.d.ts.map +1 -0
  94. package/dist/components/Stack/Stack.d.ts +18 -0
  95. package/dist/components/Stack/Stack.d.ts.map +1 -0
  96. package/dist/components/Stack/index.d.ts +3 -0
  97. package/dist/components/Stack/index.d.ts.map +1 -0
  98. package/dist/components/Switch/Switch.d.ts +18 -0
  99. package/dist/components/Switch/Switch.d.ts.map +1 -0
  100. package/dist/components/Switch/index.d.ts +3 -0
  101. package/dist/components/Switch/index.d.ts.map +1 -0
  102. package/dist/components/Table/Table.d.ts +24 -0
  103. package/dist/components/Table/Table.d.ts.map +1 -0
  104. package/dist/components/Table/index.d.ts +3 -0
  105. package/dist/components/Table/index.d.ts.map +1 -0
  106. package/dist/components/Tabs/Tabs.d.ts +19 -0
  107. package/dist/components/Tabs/Tabs.d.ts.map +1 -0
  108. package/dist/components/Tabs/index.d.ts +3 -0
  109. package/dist/components/Tabs/index.d.ts.map +1 -0
  110. package/dist/components/Tag/Tag.d.ts +18 -0
  111. package/dist/components/Tag/Tag.d.ts.map +1 -0
  112. package/dist/components/Tag/index.d.ts +3 -0
  113. package/dist/components/Tag/index.d.ts.map +1 -0
  114. package/dist/components/Textarea/Textarea.d.ts +22 -0
  115. package/dist/components/Textarea/Textarea.d.ts.map +1 -0
  116. package/dist/components/Textarea/index.d.ts +3 -0
  117. package/dist/components/Textarea/index.d.ts.map +1 -0
  118. package/dist/components/Toast/Toast.d.ts +33 -0
  119. package/dist/components/Toast/Toast.d.ts.map +1 -0
  120. package/dist/components/Toast/index.d.ts +3 -0
  121. package/dist/components/Toast/index.d.ts.map +1 -0
  122. package/dist/components/Tooltip/Tooltip.d.ts +16 -0
  123. package/dist/components/Tooltip/Tooltip.d.ts.map +1 -0
  124. package/dist/components/Tooltip/index.d.ts +3 -0
  125. package/dist/components/Tooltip/index.d.ts.map +1 -0
  126. package/dist/css/strand-ui.css +2301 -0
  127. package/dist/index.d.ts +64 -0
  128. package/dist/index.d.ts.map +1 -0
  129. package/dist/test-setup.d.ts +2 -0
  130. package/dist/test-setup.d.ts.map +1 -0
  131. package/package.json +12 -11
  132. package/src/__tests__/build-output.test.ts +77 -0
  133. package/src/components/Alert/Alert.css +67 -0
  134. package/src/components/Alert/Alert.test.tsx +92 -0
  135. package/src/components/Alert/Alert.tsx +59 -0
  136. package/src/components/Alert/index.ts +2 -0
  137. package/src/components/Avatar/Avatar.css +55 -0
  138. package/src/components/Avatar/Avatar.test.tsx +123 -0
  139. package/src/components/Avatar/Avatar.tsx +67 -0
  140. package/src/components/Avatar/index.ts +2 -0
  141. package/src/components/Badge/Badge.css +72 -0
  142. package/src/components/Badge/Badge.test.tsx +121 -0
  143. package/src/components/Badge/Badge.tsx +92 -0
  144. package/src/components/Badge/index.ts +2 -0
  145. package/src/components/Breadcrumb/Breadcrumb.css +45 -0
  146. package/src/components/Breadcrumb/Breadcrumb.test.tsx +107 -0
  147. package/src/components/Breadcrumb/Breadcrumb.tsx +59 -0
  148. package/src/components/Breadcrumb/index.ts +2 -0
  149. package/src/components/Button/Button.css +188 -0
  150. package/src/components/Button/Button.test.tsx +171 -0
  151. package/src/components/Button/Button.tsx +78 -0
  152. package/src/components/Button/index.ts +2 -0
  153. package/src/components/Card/Card.css +59 -0
  154. package/src/components/Card/Card.test.tsx +90 -0
  155. package/src/components/Card/Card.tsx +41 -0
  156. package/src/components/Card/index.ts +2 -0
  157. package/src/components/Checkbox/Checkbox.css +97 -0
  158. package/src/components/Checkbox/Checkbox.test.tsx +92 -0
  159. package/src/components/Checkbox/Checkbox.tsx +137 -0
  160. package/src/components/Checkbox/index.ts +2 -0
  161. package/src/components/Container/Container.css +25 -0
  162. package/src/components/Container/Container.test.tsx +82 -0
  163. package/src/components/Container/Container.tsx +37 -0
  164. package/src/components/Container/index.ts +2 -0
  165. package/src/components/DataReadout/DataReadout.css +30 -0
  166. package/src/components/DataReadout/DataReadout.test.tsx +105 -0
  167. package/src/components/DataReadout/DataReadout.tsx +29 -0
  168. package/src/components/DataReadout/index.ts +2 -0
  169. package/src/components/Dialog/Dialog.css +80 -0
  170. package/src/components/Dialog/Dialog.test.tsx +203 -0
  171. package/src/components/Dialog/Dialog.tsx +179 -0
  172. package/src/components/Dialog/index.ts +2 -0
  173. package/src/components/Divider/Divider.css +44 -0
  174. package/src/components/Divider/Divider.test.tsx +86 -0
  175. package/src/components/Divider/Divider.tsx +81 -0
  176. package/src/components/Divider/index.ts +2 -0
  177. package/src/components/FormField/FormField.css +47 -0
  178. package/src/components/FormField/FormField.test.tsx +99 -0
  179. package/src/components/FormField/FormField.tsx +79 -0
  180. package/src/components/FormField/index.ts +2 -0
  181. package/src/components/Grid/Grid.css +6 -0
  182. package/src/components/Grid/Grid.test.tsx +86 -0
  183. package/src/components/Grid/Grid.tsx +45 -0
  184. package/src/components/Grid/index.ts +2 -0
  185. package/src/components/Input/Input.css +80 -0
  186. package/src/components/Input/Input.test.tsx +95 -0
  187. package/src/components/Input/Input.tsx +69 -0
  188. package/src/components/Input/index.ts +2 -0
  189. package/src/components/Link/Link.css +24 -0
  190. package/src/components/Link/Link.test.tsx +88 -0
  191. package/src/components/Link/Link.tsx +31 -0
  192. package/src/components/Link/index.ts +2 -0
  193. package/src/components/Nav/Nav.css +169 -0
  194. package/src/components/Nav/Nav.test.tsx +174 -0
  195. package/src/components/Nav/Nav.tsx +101 -0
  196. package/src/components/Nav/index.ts +2 -0
  197. package/src/components/Progress/Progress.css +93 -0
  198. package/src/components/Progress/Progress.test.tsx +93 -0
  199. package/src/components/Progress/Progress.tsx +104 -0
  200. package/src/components/Progress/index.ts +2 -0
  201. package/src/components/Radio/Radio.css +98 -0
  202. package/src/components/Radio/Radio.test.tsx +80 -0
  203. package/src/components/Radio/Radio.tsx +72 -0
  204. package/src/components/Radio/index.ts +2 -0
  205. package/src/components/Section/Section.css +28 -0
  206. package/src/components/Section/Section.test.tsx +100 -0
  207. package/src/components/Section/Section.tsx +41 -0
  208. package/src/components/Section/index.ts +2 -0
  209. package/src/components/Select/Select.css +68 -0
  210. package/src/components/Select/Select.test.tsx +99 -0
  211. package/src/components/Select/Select.tsx +78 -0
  212. package/src/components/Select/index.ts +2 -0
  213. package/src/components/Skeleton/Skeleton.css +52 -0
  214. package/src/components/Skeleton/Skeleton.test.tsx +96 -0
  215. package/src/components/Skeleton/Skeleton.tsx +55 -0
  216. package/src/components/Skeleton/index.ts +2 -0
  217. package/src/components/Slider/Slider.css +107 -0
  218. package/src/components/Slider/Slider.test.tsx +85 -0
  219. package/src/components/Slider/Slider.tsx +66 -0
  220. package/src/components/Slider/index.ts +2 -0
  221. package/src/components/Spinner/Spinner.css +61 -0
  222. package/src/components/Spinner/Spinner.test.tsx +56 -0
  223. package/src/components/Spinner/Spinner.tsx +38 -0
  224. package/src/components/Spinner/index.ts +2 -0
  225. package/src/components/Stack/Stack.css +56 -0
  226. package/src/components/Stack/Stack.test.tsx +130 -0
  227. package/src/components/Stack/Stack.tsx +77 -0
  228. package/src/components/Stack/index.ts +2 -0
  229. package/src/components/Switch/Switch.css +94 -0
  230. package/src/components/Switch/Switch.test.tsx +98 -0
  231. package/src/components/Switch/Switch.tsx +80 -0
  232. package/src/components/Switch/index.ts +2 -0
  233. package/src/components/Table/Table.css +78 -0
  234. package/src/components/Table/Table.test.tsx +134 -0
  235. package/src/components/Table/Table.tsx +102 -0
  236. package/src/components/Table/index.ts +2 -0
  237. package/src/components/Tabs/Tabs.css +46 -0
  238. package/src/components/Tabs/Tabs.test.tsx +164 -0
  239. package/src/components/Tabs/Tabs.tsx +126 -0
  240. package/src/components/Tabs/index.ts +2 -0
  241. package/src/components/Tag/Tag.css +98 -0
  242. package/src/components/Tag/Tag.test.tsx +112 -0
  243. package/src/components/Tag/Tag.tsx +73 -0
  244. package/src/components/Tag/index.ts +2 -0
  245. package/src/components/Textarea/Textarea.css +73 -0
  246. package/src/components/Textarea/Textarea.test.tsx +89 -0
  247. package/src/components/Textarea/Textarea.tsx +102 -0
  248. package/src/components/Textarea/index.ts +2 -0
  249. package/src/components/Toast/Toast.css +103 -0
  250. package/src/components/Toast/Toast.test.tsx +219 -0
  251. package/src/components/Toast/Toast.tsx +177 -0
  252. package/src/components/Toast/index.ts +2 -0
  253. package/src/components/Tooltip/Tooltip.css +63 -0
  254. package/src/components/Tooltip/Tooltip.test.tsx +196 -0
  255. package/src/components/Tooltip/Tooltip.tsx +89 -0
  256. package/src/components/Tooltip/index.ts +2 -0
  257. package/src/index.ts +99 -0
  258. 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
+ &#215;
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
+ &#215;
170
+ </button>
171
+ )}
172
+ </div>
173
+ );
174
+ },
175
+ );
176
+
177
+ Toast.displayName = "Toast";
@@ -0,0 +1,2 @@
1
+ export { Toast, ToastProvider, useToast } from "./Toast.js";
2
+ export type { ToastProps, ToastProviderProps, ToastOptions, ToastStatus } from "./Toast.js";
@@ -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
+ });