@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,92 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Alert } from "./Alert.js";
4
+
5
+ describe("Alert", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders children text", () => {
9
+ const { getByRole } = render(<Alert>Something happened</Alert>);
10
+ expect(getByRole("status")).toHaveTextContent("Something happened");
11
+ });
12
+
13
+ // ── Status classes ──
14
+
15
+ it("applies info status class by default", () => {
16
+ const { getByRole } = render(<Alert>Info</Alert>);
17
+ expect(getByRole("status").className).toContain("strand-alert--info");
18
+ });
19
+
20
+ it("applies success status class", () => {
21
+ const { getByRole } = render(<Alert status="success">OK</Alert>);
22
+ expect(getByRole("status").className).toContain("strand-alert--success");
23
+ });
24
+
25
+ it("applies warning status class", () => {
26
+ const { getByRole } = render(<Alert status="warning">Warn</Alert>);
27
+ expect(getByRole("alert").className).toContain("strand-alert--warning");
28
+ });
29
+
30
+ it("applies error status class", () => {
31
+ const { getByRole } = render(<Alert status="error">Fail</Alert>);
32
+ expect(getByRole("alert").className).toContain("strand-alert--error");
33
+ });
34
+
35
+ // ── ARIA roles ──
36
+
37
+ it("uses role alert for error status", () => {
38
+ const { getByRole } = render(<Alert status="error">Err</Alert>);
39
+ expect(getByRole("alert")).toBeTruthy();
40
+ });
41
+
42
+ it("uses role alert for warning status", () => {
43
+ const { getByRole } = render(<Alert status="warning">Warn</Alert>);
44
+ expect(getByRole("alert")).toBeTruthy();
45
+ });
46
+
47
+ it("uses role status for info status", () => {
48
+ const { getByRole } = render(<Alert status="info">Info</Alert>);
49
+ expect(getByRole("status")).toBeTruthy();
50
+ });
51
+
52
+ it("uses role status for success status", () => {
53
+ const { getByRole } = render(<Alert status="success">OK</Alert>);
54
+ expect(getByRole("status")).toBeTruthy();
55
+ });
56
+
57
+ // ── Dismissible ──
58
+
59
+ it("shows dismiss button when dismissible", () => {
60
+ const { getByLabelText } = render(
61
+ <Alert dismissible>Dismissible</Alert>,
62
+ );
63
+ expect(getByLabelText("Dismiss")).toBeTruthy();
64
+ });
65
+
66
+ it("calls onDismiss when dismiss button is clicked", () => {
67
+ const onDismiss = vi.fn();
68
+ const { getByLabelText } = render(
69
+ <Alert dismissible onDismiss={onDismiss}>
70
+ Dismiss me
71
+ </Alert>,
72
+ );
73
+ fireEvent.click(getByLabelText("Dismiss"));
74
+ expect(onDismiss).toHaveBeenCalledTimes(1);
75
+ });
76
+
77
+ it("does not show dismiss button when not dismissible", () => {
78
+ const { queryByLabelText } = render(<Alert>Not dismissible</Alert>);
79
+ expect(queryByLabelText("Dismiss")).toBeNull();
80
+ });
81
+
82
+ // ── Custom className ──
83
+
84
+ it("merges custom className", () => {
85
+ const { getByRole } = render(
86
+ <Alert className="custom">Content</Alert>,
87
+ );
88
+ const el = getByRole("status");
89
+ expect(el.className).toContain("strand-alert");
90
+ expect(el.className).toContain("custom");
91
+ });
92
+ });
@@ -0,0 +1,59 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { ComponentChildren, JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface AlertProps
7
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "status"> {
8
+ /** Visual status of the alert */
9
+ status?: "info" | "success" | "warning" | "error";
10
+ /** Show dismiss button */
11
+ dismissible?: boolean;
12
+ /** Called when dismiss button is clicked */
13
+ onDismiss?: () => void;
14
+ /** Alert content */
15
+ children?: ComponentChildren;
16
+ }
17
+
18
+ export const Alert = forwardRef<HTMLDivElement, AlertProps>(
19
+ (
20
+ {
21
+ status = "info",
22
+ dismissible = false,
23
+ onDismiss,
24
+ className = "",
25
+ children,
26
+ ...rest
27
+ },
28
+ ref,
29
+ ) => {
30
+ const role =
31
+ status === "error" || status === "warning" ? "alert" : "status";
32
+
33
+ const classes = [
34
+ "strand-alert",
35
+ `strand-alert--${status}`,
36
+ className,
37
+ ]
38
+ .filter(Boolean)
39
+ .join(" ");
40
+
41
+ return (
42
+ <div ref={ref} className={classes} role={role} {...rest}>
43
+ <div className="strand-alert__content">{children}</div>
44
+ {dismissible && (
45
+ <button
46
+ type="button"
47
+ className="strand-alert__dismiss"
48
+ aria-label="Dismiss"
49
+ onClick={onDismiss}
50
+ >
51
+ &#215;
52
+ </button>
53
+ )}
54
+ </div>
55
+ );
56
+ },
57
+ );
58
+
59
+ Alert.displayName = "Alert";
@@ -0,0 +1,2 @@
1
+ export { Alert } from "./Alert.js";
2
+ export type { AlertProps } from "./Alert.js";
@@ -0,0 +1,55 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Base ── */
4
+ .strand-avatar {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ justify-content: center;
8
+ border-radius: var(--strand-radius-full);
9
+ background: var(--strand-surface-recessed);
10
+ overflow: hidden;
11
+ flex-shrink: 0;
12
+ font-family: var(--strand-font-sans);
13
+ font-weight: var(--strand-weight-medium);
14
+ color: var(--strand-gray-600);
15
+ user-select: none;
16
+ }
17
+
18
+ /* ── Image ── */
19
+ .strand-avatar__img {
20
+ width: 100%;
21
+ height: 100%;
22
+ object-fit: cover;
23
+ border-radius: var(--strand-radius-full);
24
+ }
25
+
26
+ /* ── Initials ── */
27
+ .strand-avatar__initials {
28
+ text-transform: uppercase;
29
+ line-height: 1;
30
+ }
31
+
32
+ /* ── Sizes ── */
33
+ .strand-avatar--sm {
34
+ width: 32px;
35
+ height: 32px;
36
+ font-size: var(--strand-text-xs);
37
+ }
38
+
39
+ .strand-avatar--md {
40
+ width: 40px;
41
+ height: 40px;
42
+ font-size: var(--strand-text-sm);
43
+ }
44
+
45
+ .strand-avatar--lg {
46
+ width: 48px;
47
+ height: 48px;
48
+ font-size: var(--strand-text-base);
49
+ }
50
+
51
+ .strand-avatar--xl {
52
+ width: 64px;
53
+ height: 64px;
54
+ font-size: var(--strand-text-lg);
55
+ }
@@ -0,0 +1,123 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Avatar } from "./Avatar.js";
4
+
5
+ describe("Avatar", () => {
6
+ // ── Image mode ──
7
+
8
+ it("renders image when src is provided", () => {
9
+ const { container } = render(
10
+ <Avatar src="https://example.com/photo.jpg" alt="Jane Doe" />,
11
+ );
12
+ const img = container.querySelector("img");
13
+ expect(img).toBeTruthy();
14
+ expect(img?.getAttribute("src")).toBe("https://example.com/photo.jpg");
15
+ });
16
+
17
+ it("sets alt text on image", () => {
18
+ const { container } = render(
19
+ <Avatar src="https://example.com/photo.jpg" alt="Jane Doe" />,
20
+ );
21
+ const img = container.querySelector("img");
22
+ expect(img?.getAttribute("alt")).toBe("Jane Doe");
23
+ });
24
+
25
+ // ── Initials mode ──
26
+
27
+ it("shows initials when no src is provided", () => {
28
+ const { container } = render(<Avatar initials="jd" />);
29
+ const initialsEl = container.querySelector(".strand-avatar__initials");
30
+ expect(initialsEl).toBeTruthy();
31
+ expect(initialsEl?.textContent).toBe("JD");
32
+ });
33
+
34
+ it("initials are uppercase", () => {
35
+ const { container } = render(<Avatar initials="ab" />);
36
+ const initialsEl = container.querySelector(".strand-avatar__initials");
37
+ expect(initialsEl?.textContent).toBe("AB");
38
+ });
39
+
40
+ it("truncates initials to 2 characters", () => {
41
+ const { container } = render(<Avatar initials="abc" />);
42
+ const initialsEl = container.querySelector(".strand-avatar__initials");
43
+ expect(initialsEl?.textContent).toBe("AB");
44
+ });
45
+
46
+ // ── Fallback ──
47
+
48
+ it("falls back to initials on image error", () => {
49
+ const { container } = render(
50
+ <Avatar src="https://example.com/broken.jpg" initials="jd" alt="Jane" />,
51
+ );
52
+ const img = container.querySelector("img");
53
+ expect(img).toBeTruthy();
54
+
55
+ fireEvent.error(img!);
56
+
57
+ const initialsEl = container.querySelector(".strand-avatar__initials");
58
+ expect(initialsEl).toBeTruthy();
59
+ expect(initialsEl?.textContent).toBe("JD");
60
+ expect(container.querySelector("img")).toBeNull();
61
+ });
62
+
63
+ // ── Sizes ──
64
+
65
+ it("applies md size class by default", () => {
66
+ const { container } = render(<Avatar initials="A" />);
67
+ expect(container.firstElementChild?.className).toContain(
68
+ "strand-avatar--md",
69
+ );
70
+ });
71
+
72
+ it("applies sm size class", () => {
73
+ const { container } = render(<Avatar initials="A" size="sm" />);
74
+ expect(container.firstElementChild?.className).toContain(
75
+ "strand-avatar--sm",
76
+ );
77
+ });
78
+
79
+ it("applies lg size class", () => {
80
+ const { container } = render(<Avatar initials="A" size="lg" />);
81
+ expect(container.firstElementChild?.className).toContain(
82
+ "strand-avatar--lg",
83
+ );
84
+ });
85
+
86
+ it("applies xl size class", () => {
87
+ const { container } = render(<Avatar initials="A" size="xl" />);
88
+ expect(container.firstElementChild?.className).toContain(
89
+ "strand-avatar--xl",
90
+ );
91
+ });
92
+
93
+ // ── Shape ──
94
+
95
+ it("has circular shape class", () => {
96
+ const { container } = render(<Avatar initials="A" />);
97
+ expect(container.firstElementChild?.className).toContain("strand-avatar");
98
+ });
99
+
100
+ // ── Custom className ──
101
+
102
+ it("merges custom className", () => {
103
+ const { container } = render(<Avatar initials="A" className="custom" />);
104
+ const el = container.firstElementChild;
105
+ expect(el?.className).toContain("strand-avatar");
106
+ expect(el?.className).toContain("custom");
107
+ });
108
+
109
+ // ── Accessibility ──
110
+
111
+ it("has role img and aria-label", () => {
112
+ const { container } = render(<Avatar initials="JD" alt="Jane Doe" />);
113
+ const el = container.firstElementChild;
114
+ expect(el?.getAttribute("role")).toBe("img");
115
+ expect(el?.getAttribute("aria-label")).toBe("Jane Doe");
116
+ });
117
+
118
+ it("uses initials for aria-label when no alt", () => {
119
+ const { container } = render(<Avatar initials="jd" />);
120
+ const el = container.firstElementChild;
121
+ expect(el?.getAttribute("aria-label")).toBe("JD");
122
+ });
123
+ });
@@ -0,0 +1,67 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+ import { useState, useCallback } from "preact/hooks";
6
+
7
+ export interface AvatarProps
8
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "size"> {
9
+ /** Image URL */
10
+ src?: string;
11
+ /** Alt text for image */
12
+ alt?: string;
13
+ /** Fallback initials (1-2 characters) */
14
+ initials?: string;
15
+ /** Avatar size */
16
+ size?: "sm" | "md" | "lg" | "xl";
17
+ }
18
+
19
+ export const Avatar = forwardRef<HTMLDivElement, AvatarProps>(
20
+ (
21
+ {
22
+ src,
23
+ alt = "",
24
+ initials = "",
25
+ size = "md",
26
+ className = "",
27
+ ...rest
28
+ },
29
+ ref,
30
+ ) => {
31
+ const [imgError, setImgError] = useState(false);
32
+
33
+ const handleError = useCallback(() => {
34
+ setImgError(true);
35
+ }, []);
36
+
37
+ const showImage = src && !imgError;
38
+ const displayInitials = initials.slice(0, 2).toUpperCase();
39
+
40
+ const classes = [
41
+ "strand-avatar",
42
+ `strand-avatar--${size}`,
43
+ className,
44
+ ]
45
+ .filter(Boolean)
46
+ .join(" ");
47
+
48
+ return (
49
+ <div ref={ref} className={classes} role="img" aria-label={alt || displayInitials} {...rest}>
50
+ {showImage ? (
51
+ <img
52
+ className="strand-avatar__img"
53
+ src={src}
54
+ alt={alt}
55
+ onError={handleError}
56
+ />
57
+ ) : (
58
+ <span className="strand-avatar__initials" aria-hidden="true">
59
+ {displayInitials}
60
+ </span>
61
+ )}
62
+ </div>
63
+ );
64
+ },
65
+ );
66
+
67
+ Avatar.displayName = "Avatar";
@@ -0,0 +1,2 @@
1
+ export { Avatar } from "./Avatar.js";
2
+ export type { AvatarProps } from "./Avatar.js";
@@ -0,0 +1,72 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Wrapper ── */
4
+ .strand-badge {
5
+ position: relative;
6
+ display: inline-flex;
7
+ vertical-align: middle;
8
+ }
9
+
10
+ .strand-badge--inline {
11
+ display: inline-flex;
12
+ }
13
+
14
+ /* ── Indicator (shared) ── */
15
+ .strand-badge__indicator {
16
+ display: inline-flex;
17
+ align-items: center;
18
+ justify-content: center;
19
+ font-family: var(--strand-font-sans);
20
+ font-weight: var(--strand-weight-semibold);
21
+ color: var(--strand-on-blue-primary);
22
+ }
23
+
24
+ /* Position at top-right when wrapping children */
25
+ .strand-badge:not(.strand-badge--inline) > .strand-badge__indicator {
26
+ position: absolute;
27
+ top: 0;
28
+ right: 0;
29
+ transform: translate(50%, -50%);
30
+ z-index: 1;
31
+ }
32
+
33
+ /* ── Dot variant ── */
34
+ .strand-badge--dot {
35
+ width: 8px;
36
+ height: 8px;
37
+ border-radius: var(--strand-radius-full);
38
+ font-size: 0;
39
+ padding: 0;
40
+ }
41
+
42
+ /* ── Count variant ── */
43
+ .strand-badge--count {
44
+ min-width: 20px;
45
+ height: 20px;
46
+ padding: 0 var(--strand-space-1);
47
+ border-radius: var(--strand-radius-full);
48
+ font-size: var(--strand-text-xs);
49
+ line-height: 20px;
50
+ }
51
+
52
+ /* ── Status colors ── */
53
+ .strand-badge--default {
54
+ background: var(--strand-gray-500);
55
+ }
56
+
57
+ .strand-badge--teal {
58
+ background: var(--strand-teal-vital);
59
+ }
60
+
61
+ .strand-badge--blue {
62
+ background: var(--strand-blue-primary);
63
+ }
64
+
65
+ .strand-badge--amber {
66
+ background: var(--strand-amber-caution);
67
+ color: var(--strand-blue-midnight);
68
+ }
69
+
70
+ .strand-badge--red {
71
+ background: var(--strand-red-alert);
72
+ }
@@ -0,0 +1,121 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { Badge } from "./Badge.js";
4
+
5
+ describe("Badge", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders a span element", () => {
9
+ const { container } = render(<Badge count={5} />);
10
+ expect(container.firstElementChild?.tagName).toBe("SPAN");
11
+ });
12
+
13
+ // ── Count variant ──
14
+
15
+ it("displays count number", () => {
16
+ const { getByRole } = render(<Badge count={7} />);
17
+ expect(getByRole("status")).toHaveTextContent("7");
18
+ });
19
+
20
+ it("truncates count at maxCount", () => {
21
+ const { getByRole } = render(<Badge count={150} maxCount={99} />);
22
+ expect(getByRole("status")).toHaveTextContent("99+");
23
+ });
24
+
25
+ it("does not truncate count at maxCount boundary", () => {
26
+ const { getByRole } = render(<Badge count={99} maxCount={99} />);
27
+ expect(getByRole("status")).toHaveTextContent("99");
28
+ });
29
+
30
+ it("supports custom maxCount", () => {
31
+ const { getByRole } = render(<Badge count={15} maxCount={9} />);
32
+ expect(getByRole("status")).toHaveTextContent("9+");
33
+ });
34
+
35
+ // ── Dot variant ──
36
+
37
+ it("renders dot variant as small circle", () => {
38
+ const { container } = render(<Badge variant="dot" />);
39
+ const indicator = container.querySelector(".strand-badge__indicator");
40
+ expect(indicator?.className).toContain("strand-badge--dot");
41
+ });
42
+
43
+ // ── Status colors ──
44
+
45
+ it("applies default status class by default", () => {
46
+ const { container } = render(<Badge count={1} />);
47
+ const indicator = container.querySelector(".strand-badge__indicator");
48
+ expect(indicator?.className).toContain("strand-badge--default");
49
+ });
50
+
51
+ it("applies teal status class", () => {
52
+ const { container } = render(<Badge count={1} status="teal" />);
53
+ const indicator = container.querySelector(".strand-badge__indicator");
54
+ expect(indicator?.className).toContain("strand-badge--teal");
55
+ });
56
+
57
+ it("applies red status class", () => {
58
+ const { container } = render(<Badge count={1} status="red" />);
59
+ const indicator = container.querySelector(".strand-badge__indicator");
60
+ expect(indicator?.className).toContain("strand-badge--red");
61
+ });
62
+
63
+ it("applies amber status class", () => {
64
+ const { container } = render(<Badge count={1} status="amber" />);
65
+ const indicator = container.querySelector(".strand-badge__indicator");
66
+ expect(indicator?.className).toContain("strand-badge--amber");
67
+ });
68
+
69
+ it("applies blue status class", () => {
70
+ const { container } = render(<Badge count={1} status="blue" />);
71
+ const indicator = container.querySelector(".strand-badge__indicator");
72
+ expect(indicator?.className).toContain("strand-badge--blue");
73
+ });
74
+
75
+ // ── Children wrapping ──
76
+
77
+ it("wraps children and positions badge", () => {
78
+ const { getByText, container } = render(
79
+ <Badge count={3}>
80
+ <span>Inbox</span>
81
+ </Badge>,
82
+ );
83
+ expect(getByText("Inbox")).toBeTruthy();
84
+ const wrapper = container.firstElementChild;
85
+ expect(wrapper?.className).toContain("strand-badge");
86
+ expect(wrapper?.className).not.toContain("strand-badge--inline");
87
+ });
88
+
89
+ it("renders inline without children", () => {
90
+ const { container } = render(<Badge count={5} />);
91
+ const wrapper = container.firstElementChild;
92
+ expect(wrapper?.className).toContain("strand-badge--inline");
93
+ });
94
+
95
+ // ── Custom className ──
96
+
97
+ it("merges custom className", () => {
98
+ const { container } = render(<Badge count={1} className="custom" />);
99
+ const el = container.firstElementChild;
100
+ expect(el?.className).toContain("strand-badge");
101
+ expect(el?.className).toContain("custom");
102
+ });
103
+
104
+ // ── Accessibility ──
105
+
106
+ it("has aria-label for count variant", () => {
107
+ const { getByRole } = render(<Badge count={5} />);
108
+ expect(getByRole("status")).toHaveAttribute(
109
+ "aria-label",
110
+ "5 notifications",
111
+ );
112
+ });
113
+
114
+ it("has aria-label for dot variant", () => {
115
+ const { getByRole } = render(<Badge variant="dot" />);
116
+ expect(getByRole("status")).toHaveAttribute(
117
+ "aria-label",
118
+ "Status indicator",
119
+ );
120
+ });
121
+ });
@@ -0,0 +1,92 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { ComponentChildren, JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface BadgeProps
7
+ extends Omit<JSX.HTMLAttributes<HTMLSpanElement>, "children"> {
8
+ /** Badge display mode */
9
+ variant?: "dot" | "count";
10
+ /** Color status */
11
+ status?: "default" | "teal" | "blue" | "amber" | "red";
12
+ /** Number to display (count variant only) */
13
+ count?: number;
14
+ /** Maximum count before showing "N+" */
15
+ maxCount?: number;
16
+ /** Wrapped content; when present badge is positioned at top-right */
17
+ children?: ComponentChildren;
18
+ }
19
+
20
+ export const Badge = forwardRef<HTMLSpanElement, BadgeProps>(
21
+ (
22
+ {
23
+ variant = "count",
24
+ status = "default",
25
+ count,
26
+ maxCount = 99,
27
+ className = "",
28
+ children,
29
+ ...rest
30
+ },
31
+ ref,
32
+ ) => {
33
+ const hasChildren = children != null && children !== false;
34
+
35
+ const displayValue =
36
+ variant === "count"
37
+ ? count != null && count > maxCount
38
+ ? `${maxCount}+`
39
+ : count
40
+ : null;
41
+
42
+ const ariaLabel =
43
+ variant === "dot"
44
+ ? "Status indicator"
45
+ : count != null
46
+ ? `${count} notifications`
47
+ : undefined;
48
+
49
+ const badgeClasses = [
50
+ "strand-badge__indicator",
51
+ `strand-badge--${variant}`,
52
+ `strand-badge--${status}`,
53
+ ]
54
+ .filter(Boolean)
55
+ .join(" ");
56
+
57
+ const badge = (
58
+ <span className={badgeClasses} aria-label={ariaLabel} role="status">
59
+ {displayValue}
60
+ </span>
61
+ );
62
+
63
+ if (!hasChildren) {
64
+ const inlineClasses = [
65
+ "strand-badge",
66
+ "strand-badge--inline",
67
+ className,
68
+ ]
69
+ .filter(Boolean)
70
+ .join(" ");
71
+
72
+ return (
73
+ <span ref={ref} className={inlineClasses} {...rest}>
74
+ {badge}
75
+ </span>
76
+ );
77
+ }
78
+
79
+ const wrapperClasses = ["strand-badge", className]
80
+ .filter(Boolean)
81
+ .join(" ");
82
+
83
+ return (
84
+ <span ref={ref} className={wrapperClasses} {...rest}>
85
+ {children}
86
+ {badge}
87
+ </span>
88
+ );
89
+ },
90
+ );
91
+
92
+ Badge.displayName = "Badge";
@@ -0,0 +1,2 @@
1
+ export { Badge } from "./Badge.js";
2
+ export type { BadgeProps } from "./Badge.js";