@dillingerstaffing/strand-ui 0.1.0 → 0.2.0

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 (261) 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 +2464 -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 +25 -11
  132. package/src/__tests__/build-output.test.ts +200 -0
  133. package/src/__tests__/design-language.test.ts +137 -0
  134. package/src/__tests__/static.test.tsx +60 -0
  135. package/src/components/Alert/Alert.css +75 -0
  136. package/src/components/Alert/Alert.test.tsx +92 -0
  137. package/src/components/Alert/Alert.tsx +59 -0
  138. package/src/components/Alert/index.ts +2 -0
  139. package/src/components/Avatar/Avatar.css +55 -0
  140. package/src/components/Avatar/Avatar.test.tsx +123 -0
  141. package/src/components/Avatar/Avatar.tsx +67 -0
  142. package/src/components/Avatar/index.ts +2 -0
  143. package/src/components/Badge/Badge.css +72 -0
  144. package/src/components/Badge/Badge.test.tsx +121 -0
  145. package/src/components/Badge/Badge.tsx +92 -0
  146. package/src/components/Badge/index.ts +2 -0
  147. package/src/components/Breadcrumb/Breadcrumb.css +50 -0
  148. package/src/components/Breadcrumb/Breadcrumb.test.tsx +107 -0
  149. package/src/components/Breadcrumb/Breadcrumb.tsx +59 -0
  150. package/src/components/Breadcrumb/index.ts +2 -0
  151. package/src/components/Button/Button.css +195 -0
  152. package/src/components/Button/Button.test.tsx +171 -0
  153. package/src/components/Button/Button.tsx +78 -0
  154. package/src/components/Button/index.ts +2 -0
  155. package/src/components/Card/Card.css +68 -0
  156. package/src/components/Card/Card.test.tsx +90 -0
  157. package/src/components/Card/Card.tsx +41 -0
  158. package/src/components/Card/index.ts +2 -0
  159. package/src/components/Checkbox/Checkbox.css +97 -0
  160. package/src/components/Checkbox/Checkbox.test.tsx +92 -0
  161. package/src/components/Checkbox/Checkbox.tsx +137 -0
  162. package/src/components/Checkbox/index.ts +2 -0
  163. package/src/components/Container/Container.css +25 -0
  164. package/src/components/Container/Container.test.tsx +82 -0
  165. package/src/components/Container/Container.tsx +37 -0
  166. package/src/components/Container/index.ts +2 -0
  167. package/src/components/DataReadout/DataReadout.css +30 -0
  168. package/src/components/DataReadout/DataReadout.test.tsx +105 -0
  169. package/src/components/DataReadout/DataReadout.tsx +29 -0
  170. package/src/components/DataReadout/index.ts +2 -0
  171. package/src/components/Dialog/Dialog.css +81 -0
  172. package/src/components/Dialog/Dialog.test.tsx +203 -0
  173. package/src/components/Dialog/Dialog.tsx +179 -0
  174. package/src/components/Dialog/index.ts +2 -0
  175. package/src/components/Divider/Divider.css +44 -0
  176. package/src/components/Divider/Divider.test.tsx +86 -0
  177. package/src/components/Divider/Divider.tsx +81 -0
  178. package/src/components/Divider/index.ts +2 -0
  179. package/src/components/FormField/FormField.css +47 -0
  180. package/src/components/FormField/FormField.test.tsx +99 -0
  181. package/src/components/FormField/FormField.tsx +79 -0
  182. package/src/components/FormField/index.ts +2 -0
  183. package/src/components/Grid/Grid.css +27 -0
  184. package/src/components/Grid/Grid.test.tsx +86 -0
  185. package/src/components/Grid/Grid.tsx +45 -0
  186. package/src/components/Grid/index.ts +2 -0
  187. package/src/components/Input/Input.css +87 -0
  188. package/src/components/Input/Input.test.tsx +95 -0
  189. package/src/components/Input/Input.tsx +69 -0
  190. package/src/components/Input/index.ts +2 -0
  191. package/src/components/Link/Link.css +30 -0
  192. package/src/components/Link/Link.test.tsx +88 -0
  193. package/src/components/Link/Link.tsx +31 -0
  194. package/src/components/Link/index.ts +2 -0
  195. package/src/components/Nav/Nav.css +179 -0
  196. package/src/components/Nav/Nav.test.tsx +174 -0
  197. package/src/components/Nav/Nav.tsx +101 -0
  198. package/src/components/Nav/index.ts +2 -0
  199. package/src/components/Progress/Progress.css +93 -0
  200. package/src/components/Progress/Progress.test.tsx +93 -0
  201. package/src/components/Progress/Progress.tsx +104 -0
  202. package/src/components/Progress/index.ts +2 -0
  203. package/src/components/Radio/Radio.css +98 -0
  204. package/src/components/Radio/Radio.test.tsx +80 -0
  205. package/src/components/Radio/Radio.tsx +72 -0
  206. package/src/components/Radio/index.ts +2 -0
  207. package/src/components/Section/Section.css +28 -0
  208. package/src/components/Section/Section.test.tsx +100 -0
  209. package/src/components/Section/Section.tsx +41 -0
  210. package/src/components/Section/index.ts +2 -0
  211. package/src/components/Select/Select.css +75 -0
  212. package/src/components/Select/Select.test.tsx +99 -0
  213. package/src/components/Select/Select.tsx +78 -0
  214. package/src/components/Select/index.ts +2 -0
  215. package/src/components/Skeleton/Skeleton.css +52 -0
  216. package/src/components/Skeleton/Skeleton.test.tsx +96 -0
  217. package/src/components/Skeleton/Skeleton.tsx +55 -0
  218. package/src/components/Skeleton/index.ts +2 -0
  219. package/src/components/Slider/Slider.css +107 -0
  220. package/src/components/Slider/Slider.test.tsx +85 -0
  221. package/src/components/Slider/Slider.tsx +66 -0
  222. package/src/components/Slider/index.ts +2 -0
  223. package/src/components/Spinner/Spinner.css +61 -0
  224. package/src/components/Spinner/Spinner.test.tsx +56 -0
  225. package/src/components/Spinner/Spinner.tsx +38 -0
  226. package/src/components/Spinner/index.ts +2 -0
  227. package/src/components/Stack/Stack.css +71 -0
  228. package/src/components/Stack/Stack.test.tsx +130 -0
  229. package/src/components/Stack/Stack.tsx +77 -0
  230. package/src/components/Stack/index.ts +2 -0
  231. package/src/components/Switch/Switch.css +94 -0
  232. package/src/components/Switch/Switch.test.tsx +98 -0
  233. package/src/components/Switch/Switch.tsx +80 -0
  234. package/src/components/Switch/index.ts +2 -0
  235. package/src/components/Table/Table.css +83 -0
  236. package/src/components/Table/Table.test.tsx +134 -0
  237. package/src/components/Table/Table.tsx +102 -0
  238. package/src/components/Table/index.ts +2 -0
  239. package/src/components/Tabs/Tabs.css +51 -0
  240. package/src/components/Tabs/Tabs.test.tsx +164 -0
  241. package/src/components/Tabs/Tabs.tsx +126 -0
  242. package/src/components/Tabs/index.ts +2 -0
  243. package/src/components/Tag/Tag.css +98 -0
  244. package/src/components/Tag/Tag.test.tsx +112 -0
  245. package/src/components/Tag/Tag.tsx +73 -0
  246. package/src/components/Tag/index.ts +2 -0
  247. package/src/components/Textarea/Textarea.css +80 -0
  248. package/src/components/Textarea/Textarea.test.tsx +89 -0
  249. package/src/components/Textarea/Textarea.tsx +102 -0
  250. package/src/components/Textarea/index.ts +2 -0
  251. package/src/components/Toast/Toast.css +103 -0
  252. package/src/components/Toast/Toast.test.tsx +219 -0
  253. package/src/components/Toast/Toast.tsx +177 -0
  254. package/src/components/Toast/index.ts +2 -0
  255. package/src/components/Tooltip/Tooltip.css +63 -0
  256. package/src/components/Tooltip/Tooltip.test.tsx +196 -0
  257. package/src/components/Tooltip/Tooltip.tsx +89 -0
  258. package/src/components/Tooltip/index.ts +2 -0
  259. package/src/index.ts +99 -0
  260. package/src/static.css +47 -0
  261. package/src/test-setup.ts +7 -0
@@ -0,0 +1,89 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Textarea } from "./Textarea.js";
4
+
5
+ describe("Textarea", () => {
6
+ it("renders a textarea element", () => {
7
+ const { container } = render(<Textarea aria-label="Message" />);
8
+ expect(container.querySelector("textarea")).toBeTruthy();
9
+ });
10
+
11
+ it("displays placeholder text", () => {
12
+ const { getByPlaceholderText } = render(
13
+ <Textarea placeholder="Enter message" aria-label="Message" />
14
+ );
15
+ expect(getByPlaceholderText("Enter message")).toBeTruthy();
16
+ });
17
+
18
+ it("calls onInput when value changes", () => {
19
+ const onInput = vi.fn();
20
+ const { container } = render(
21
+ <Textarea aria-label="Message" onInput={onInput} />
22
+ );
23
+ fireEvent.input(container.querySelector("textarea")!, {
24
+ target: { value: "hello" },
25
+ });
26
+ expect(onInput).toHaveBeenCalled();
27
+ });
28
+
29
+ it("shows character count when showCount and maxLength are set", () => {
30
+ const { container } = render(
31
+ <Textarea aria-label="Message" showCount maxLength={100} value="hello" />
32
+ );
33
+ const count = container.querySelector(".strand-textarea__count");
34
+ expect(count).toBeTruthy();
35
+ expect(count!.textContent).toBe("5/100");
36
+ });
37
+
38
+ it("does not show character count without showCount", () => {
39
+ const { container } = render(
40
+ <Textarea aria-label="Message" maxLength={100} value="hello" />
41
+ );
42
+ expect(container.querySelector(".strand-textarea__count")).toBeNull();
43
+ });
44
+
45
+ it("applies auto-resize class when autoResize is true", () => {
46
+ const { container } = render(
47
+ <Textarea aria-label="Message" autoResize />
48
+ );
49
+ expect(
50
+ container.querySelector(".strand-textarea--auto-resize")
51
+ ).toBeTruthy();
52
+ });
53
+
54
+ it("applies error class when error is true", () => {
55
+ const { container } = render(<Textarea aria-label="Message" error />);
56
+ expect(
57
+ container.querySelector(".strand-textarea--error")
58
+ ).toBeTruthy();
59
+ });
60
+
61
+ it("sets aria-invalid when error is true", () => {
62
+ const { container } = render(<Textarea aria-label="Message" error />);
63
+ expect(container.querySelector("textarea")).toHaveAttribute(
64
+ "aria-invalid",
65
+ "true"
66
+ );
67
+ });
68
+
69
+ it("sets disabled attribute when disabled", () => {
70
+ const { container } = render(<Textarea aria-label="Message" disabled />);
71
+ expect(container.querySelector("textarea")).toBeDisabled();
72
+ });
73
+
74
+ it("applies disabled class when disabled", () => {
75
+ const { container } = render(<Textarea aria-label="Message" disabled />);
76
+ expect(
77
+ container.querySelector(".strand-textarea--disabled")
78
+ ).toBeTruthy();
79
+ });
80
+
81
+ it("merges custom className", () => {
82
+ const { container } = render(
83
+ <Textarea aria-label="Message" className="custom" />
84
+ );
85
+ const wrapper = container.querySelector(".strand-textarea");
86
+ expect(wrapper?.className).toContain("custom");
87
+ expect(wrapper?.className).toContain("strand-textarea");
88
+ });
89
+ });
@@ -0,0 +1,102 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+ import { useCallback, useRef } from "preact/hooks";
6
+
7
+ export interface TextareaProps
8
+ extends Omit<JSX.HTMLAttributes<HTMLTextAreaElement>, "onInput" | "value"> {
9
+ /** Auto-resize to fit content */
10
+ autoResize?: boolean;
11
+ /** Show character count (requires maxLength) */
12
+ showCount?: boolean;
13
+ /** Show error styling */
14
+ error?: boolean;
15
+ /** Maximum character count */
16
+ maxLength?: number;
17
+ /** Input handler */
18
+ onInput?: JSX.GenericEventHandler<HTMLTextAreaElement>;
19
+ /** Disabled state */
20
+ disabled?: boolean;
21
+ /** Controlled value */
22
+ value?: string;
23
+ }
24
+
25
+ export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
26
+ (
27
+ {
28
+ autoResize = false,
29
+ showCount = false,
30
+ error = false,
31
+ maxLength,
32
+ disabled,
33
+ className = "",
34
+ value,
35
+ onInput,
36
+ ...rest
37
+ },
38
+ ref,
39
+ ) => {
40
+ const internalRef = useRef<HTMLTextAreaElement | null>(null);
41
+
42
+ const setRef = useCallback(
43
+ (el: HTMLTextAreaElement | null) => {
44
+ internalRef.current = el;
45
+ if (typeof ref === "function") {
46
+ ref(el);
47
+ } else if (ref) {
48
+ (ref as { current: HTMLTextAreaElement | null }).current = el;
49
+ }
50
+ },
51
+ [ref],
52
+ );
53
+
54
+ const handleInput: JSX.GenericEventHandler<HTMLTextAreaElement> = useCallback(
55
+ (e) => {
56
+ if (autoResize && internalRef.current) {
57
+ internalRef.current.style.height = "auto";
58
+ internalRef.current.style.height = `${internalRef.current.scrollHeight}px`;
59
+ }
60
+ if (onInput) {
61
+ onInput(e);
62
+ }
63
+ },
64
+ [autoResize, onInput],
65
+ );
66
+
67
+ const wrapperClasses = [
68
+ "strand-textarea",
69
+ error && "strand-textarea--error",
70
+ disabled && "strand-textarea--disabled",
71
+ autoResize && "strand-textarea--auto-resize",
72
+ className,
73
+ ]
74
+ .filter(Boolean)
75
+ .join(" ");
76
+
77
+ const currentLength =
78
+ typeof value === "string" ? value.length : 0;
79
+
80
+ return (
81
+ <div className={wrapperClasses}>
82
+ <textarea
83
+ ref={setRef}
84
+ className="strand-textarea__field"
85
+ disabled={disabled}
86
+ aria-invalid={error ? "true" : undefined}
87
+ maxLength={maxLength}
88
+ value={value}
89
+ onInput={handleInput}
90
+ {...rest}
91
+ />
92
+ {showCount && maxLength != null && (
93
+ <span className="strand-textarea__count" aria-live="polite">
94
+ {currentLength}/{maxLength}
95
+ </span>
96
+ )}
97
+ </div>
98
+ );
99
+ },
100
+ );
101
+
102
+ Textarea.displayName = "Textarea";
@@ -0,0 +1,2 @@
1
+ export { Textarea } from "./Textarea.js";
2
+ export type { TextareaProps } from "./Textarea.js";
@@ -0,0 +1,103 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Container ── */
4
+ .strand-toast__container {
5
+ position: fixed;
6
+ right: var(--strand-space-6);
7
+ bottom: var(--strand-space-6);
8
+ z-index: 1100;
9
+ display: flex;
10
+ flex-direction: column-reverse;
11
+ gap: var(--strand-space-3);
12
+ pointer-events: none;
13
+ }
14
+
15
+ /* ── Toast ── */
16
+ .strand-toast {
17
+ display: flex;
18
+ align-items: center;
19
+ justify-content: space-between;
20
+ min-width: 280px;
21
+ max-width: 420px;
22
+ padding: var(--strand-space-4) var(--strand-space-5);
23
+ background: var(--strand-surface-elevated);
24
+ border-radius: var(--strand-radius-lg);
25
+ border-left: 4px solid transparent;
26
+ box-shadow: var(--strand-elevation-3);
27
+ font-family: var(--strand-font-sans);
28
+ font-size: var(--strand-text-sm);
29
+ pointer-events: auto;
30
+ animation: strand-toast-in var(--strand-duration-normal) var(--strand-ease-out-expo);
31
+ }
32
+
33
+ /* ── Status accents ── */
34
+ .strand-toast--info {
35
+ border-left-color: var(--strand-blue-primary);
36
+ }
37
+
38
+ .strand-toast--success {
39
+ border-left-color: var(--strand-green-positive);
40
+ }
41
+
42
+ .strand-toast--warning {
43
+ border-left-color: var(--strand-amber-caution);
44
+ }
45
+
46
+ .strand-toast--error {
47
+ border-left-color: var(--strand-red-alert);
48
+ }
49
+
50
+ /* ── Message ── */
51
+ .strand-toast__message {
52
+ flex: 1;
53
+ min-width: 0;
54
+ color: var(--strand-gray-900);
55
+ }
56
+
57
+ /* ── Dismiss button ── */
58
+ .strand-toast__dismiss {
59
+ flex-shrink: 0;
60
+ display: inline-flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ width: 24px;
64
+ height: 24px;
65
+ margin-left: var(--strand-space-3);
66
+ padding: 0;
67
+ border: none;
68
+ border-radius: var(--strand-radius-md);
69
+ background: transparent;
70
+ color: var(--strand-gray-500);
71
+ font-size: var(--strand-text-base);
72
+ cursor: pointer;
73
+ transition: background var(--strand-duration-fast) var(--strand-ease-out-quart),
74
+ color var(--strand-duration-fast) var(--strand-ease-out-quart);
75
+ }
76
+
77
+ .strand-toast__dismiss:hover {
78
+ background: var(--strand-gray-200);
79
+ color: var(--strand-gray-600);
80
+ }
81
+
82
+ /* ── Animations ── */
83
+ @keyframes strand-toast-in {
84
+ from {
85
+ opacity: 0;
86
+ transform: translateY(8px);
87
+ }
88
+ to {
89
+ opacity: 1;
90
+ transform: translateY(0);
91
+ }
92
+ }
93
+
94
+ /* ── Reduced motion ── */
95
+ @media (prefers-reduced-motion: reduce) {
96
+ .strand-toast {
97
+ animation: none;
98
+ }
99
+
100
+ .strand-toast__dismiss {
101
+ transition: none;
102
+ }
103
+ }
@@ -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";