@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,171 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Button } from "./Button.js";
4
+
5
+ describe("Button", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders with children text", () => {
9
+ const { getByRole } = render(<Button>Click me</Button>);
10
+ expect(getByRole("button")).toHaveTextContent("Click me");
11
+ });
12
+
13
+ it("renders as a button element by default", () => {
14
+ const { getByRole } = render(<Button>Test</Button>);
15
+ expect(getByRole("button").tagName).toBe("BUTTON");
16
+ });
17
+
18
+ it("has type='button' by default to prevent form submission", () => {
19
+ const { getByRole } = render(<Button>Test</Button>);
20
+ expect(getByRole("button")).toHaveAttribute("type", "button");
21
+ });
22
+
23
+ it("allows type override to submit", () => {
24
+ const { getByRole } = render(<Button type="submit">Submit</Button>);
25
+ expect(getByRole("button")).toHaveAttribute("type", "submit");
26
+ });
27
+
28
+ // ── Variants ──
29
+
30
+ it("applies primary variant class by default", () => {
31
+ const { getByRole } = render(<Button>Test</Button>);
32
+ expect(getByRole("button").className).toContain("strand-btn--primary");
33
+ });
34
+
35
+ it("applies secondary variant class", () => {
36
+ const { getByRole } = render(<Button variant="secondary">Test</Button>);
37
+ expect(getByRole("button").className).toContain("strand-btn--secondary");
38
+ });
39
+
40
+ it("applies ghost variant class", () => {
41
+ const { getByRole } = render(<Button variant="ghost">Test</Button>);
42
+ expect(getByRole("button").className).toContain("strand-btn--ghost");
43
+ });
44
+
45
+ it("applies danger variant class", () => {
46
+ const { getByRole } = render(<Button variant="danger">Test</Button>);
47
+ expect(getByRole("button").className).toContain("strand-btn--danger");
48
+ });
49
+
50
+ // ── Sizes ──
51
+
52
+ it("applies md size class by default", () => {
53
+ const { getByRole } = render(<Button>Test</Button>);
54
+ expect(getByRole("button").className).toContain("strand-btn--md");
55
+ });
56
+
57
+ it("applies sm size class", () => {
58
+ const { getByRole } = render(<Button size="sm">Test</Button>);
59
+ expect(getByRole("button").className).toContain("strand-btn--sm");
60
+ });
61
+
62
+ it("applies lg size class", () => {
63
+ const { getByRole } = render(<Button size="lg">Test</Button>);
64
+ expect(getByRole("button").className).toContain("strand-btn--lg");
65
+ });
66
+
67
+ // ── Icon-only ──
68
+
69
+ it("applies icon-only class when iconOnly is true", () => {
70
+ const { getByRole } = render(
71
+ <Button iconOnly aria-label="Close">X</Button>
72
+ );
73
+ expect(getByRole("button").className).toContain("strand-btn--icon-only");
74
+ });
75
+
76
+ // ── Interaction ──
77
+
78
+ it("calls onClick when clicked", () => {
79
+ const onClick = vi.fn();
80
+ const { getByRole } = render(<Button onClick={onClick}>Click</Button>);
81
+ fireEvent.click(getByRole("button"));
82
+ expect(onClick).toHaveBeenCalledTimes(1);
83
+ });
84
+
85
+ it("does not call onClick when disabled", () => {
86
+ const onClick = vi.fn();
87
+ const { getByRole } = render(
88
+ <Button onClick={onClick} disabled>Click</Button>
89
+ );
90
+ fireEvent.click(getByRole("button"));
91
+ expect(onClick).not.toHaveBeenCalled();
92
+ });
93
+
94
+ // ── Disabled state ──
95
+
96
+ it("sets disabled attribute when disabled", () => {
97
+ const { getByRole } = render(<Button disabled>Test</Button>);
98
+ expect(getByRole("button")).toBeDisabled();
99
+ });
100
+
101
+ it("sets aria-disabled when disabled", () => {
102
+ const { getByRole } = render(<Button disabled>Test</Button>);
103
+ expect(getByRole("button")).toHaveAttribute("aria-disabled", "true");
104
+ });
105
+
106
+ // ── Loading state ──
107
+
108
+ it("shows loading state", () => {
109
+ const { getByRole } = render(<Button loading>Test</Button>);
110
+ const btn = getByRole("button");
111
+ expect(btn.className).toContain("strand-btn--loading");
112
+ expect(btn).toHaveAttribute("aria-busy", "true");
113
+ });
114
+
115
+ it("disables interaction when loading", () => {
116
+ const onClick = vi.fn();
117
+ const { getByRole } = render(
118
+ <Button loading onClick={onClick}>Test</Button>
119
+ );
120
+ fireEvent.click(getByRole("button"));
121
+ expect(onClick).not.toHaveBeenCalled();
122
+ });
123
+
124
+ it("renders spinner element when loading", () => {
125
+ const { container } = render(<Button loading>Test</Button>);
126
+ expect(container.querySelector(".strand-btn__spinner")).toBeTruthy();
127
+ });
128
+
129
+ // ── Accessibility ──
130
+
131
+ it("is focusable via keyboard", () => {
132
+ const { getByRole } = render(<Button>Test</Button>);
133
+ const btn = getByRole("button");
134
+ btn.focus();
135
+ expect(document.activeElement).toBe(btn);
136
+ });
137
+
138
+ it("is not focusable when disabled", () => {
139
+ const { getByRole } = render(<Button disabled>Test</Button>);
140
+ const btn = getByRole("button");
141
+ expect(btn).toBeDisabled();
142
+ });
143
+
144
+ it("supports aria-label", () => {
145
+ const { getByRole } = render(<Button aria-label="Close dialog">X</Button>);
146
+ expect(getByRole("button")).toHaveAttribute("aria-label", "Close dialog");
147
+ });
148
+
149
+ it("forwards additional props", () => {
150
+ const { getByRole } = render(
151
+ <Button data-testid="custom" id="my-btn">Test</Button>
152
+ );
153
+ expect(getByRole("button")).toHaveAttribute("id", "my-btn");
154
+ });
155
+
156
+ // ── Custom className ──
157
+
158
+ it("merges custom className with component classes", () => {
159
+ const { getByRole } = render(<Button className="custom">Test</Button>);
160
+ const btn = getByRole("button");
161
+ expect(btn.className).toContain("strand-btn");
162
+ expect(btn.className).toContain("custom");
163
+ });
164
+
165
+ // ── Full width ──
166
+
167
+ it("applies full-width class when fullWidth is true", () => {
168
+ const { getByRole } = render(<Button fullWidth>Test</Button>);
169
+ expect(getByRole("button").className).toContain("strand-btn--full-width");
170
+ });
171
+ });
@@ -0,0 +1,78 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface ButtonProps
7
+ extends Omit<JSX.HTMLAttributes<HTMLButtonElement>, "size" | "loading" | "type"> {
8
+ /** Visual style variant */
9
+ variant?: "primary" | "secondary" | "ghost" | "danger";
10
+ /** Button size */
11
+ size?: "sm" | "md" | "lg";
12
+ /** Show loading spinner and disable interaction */
13
+ loading?: boolean;
14
+ /** Square button for icon-only use */
15
+ iconOnly?: boolean;
16
+ /** HTML button type */
17
+ type?: "button" | "submit" | "reset";
18
+ /** Disabled state */
19
+ disabled?: boolean;
20
+ /** Stretch to full container width */
21
+ fullWidth?: boolean;
22
+ }
23
+
24
+ export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
25
+ (
26
+ {
27
+ variant = "primary",
28
+ size = "md",
29
+ loading = false,
30
+ iconOnly = false,
31
+ fullWidth = false,
32
+ disabled = false,
33
+ className = "",
34
+ children,
35
+ onClick,
36
+ type = "button",
37
+ ...rest
38
+ },
39
+ ref,
40
+ ) => {
41
+ const isDisabled = disabled || loading;
42
+
43
+ const classes = [
44
+ "strand-btn",
45
+ `strand-btn--${variant}`,
46
+ `strand-btn--${size}`,
47
+ iconOnly && "strand-btn--icon-only",
48
+ fullWidth && "strand-btn--full-width",
49
+ loading && "strand-btn--loading",
50
+ className,
51
+ ]
52
+ .filter(Boolean)
53
+ .join(" ");
54
+
55
+ return (
56
+ <button
57
+ ref={ref}
58
+ type={type}
59
+ className={classes}
60
+ disabled={isDisabled}
61
+ aria-disabled={isDisabled ? "true" : undefined}
62
+ aria-busy={loading ? "true" : undefined}
63
+ onClick={isDisabled ? undefined : onClick}
64
+ {...rest}
65
+ >
66
+ {loading && <span className="strand-btn__spinner" aria-hidden="true" />}
67
+ <span
68
+ className="strand-btn__content"
69
+ style={loading ? { visibility: "hidden" } : undefined}
70
+ >
71
+ {children}
72
+ </span>
73
+ </button>
74
+ );
75
+ },
76
+ );
77
+
78
+ Button.displayName = "Button";
@@ -0,0 +1,2 @@
1
+ export { Button } from "./Button.js";
2
+ export type { ButtonProps } from "./Button.js";
@@ -0,0 +1,59 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Base ── */
4
+ .strand-card {
5
+ border-radius: var(--strand-radius-lg);
6
+ background: var(--strand-surface-elevated);
7
+ font-family: var(--strand-font-sans);
8
+ }
9
+
10
+ /* ── Variants ── */
11
+ .strand-card--elevated {
12
+ box-shadow: var(--strand-elevation-1);
13
+ }
14
+
15
+ .strand-card--outlined {
16
+ box-shadow: none;
17
+ border: 1px solid var(--strand-gray-200);
18
+ }
19
+
20
+ .strand-card--interactive {
21
+ box-shadow: var(--strand-elevation-1);
22
+ cursor: pointer;
23
+ transition:
24
+ transform var(--strand-duration-fast) var(--strand-ease-out-expo),
25
+ box-shadow var(--strand-duration-fast) var(--strand-ease-out-expo);
26
+ }
27
+
28
+ .strand-card--interactive:hover {
29
+ transform: translateY(-2px);
30
+ box-shadow: var(--strand-elevation-2);
31
+ }
32
+
33
+ /* ── Padding ── */
34
+ .strand-card--pad-none {
35
+ padding: 0;
36
+ }
37
+
38
+ .strand-card--pad-sm {
39
+ padding: var(--strand-space-3);
40
+ }
41
+
42
+ .strand-card--pad-md {
43
+ padding: var(--strand-space-5);
44
+ }
45
+
46
+ .strand-card--pad-lg {
47
+ padding: var(--strand-space-8);
48
+ }
49
+
50
+ /* ── Reduced motion ── */
51
+ @media (prefers-reduced-motion: reduce) {
52
+ .strand-card--interactive {
53
+ transition: none;
54
+ }
55
+
56
+ .strand-card--interactive:hover {
57
+ transform: none;
58
+ }
59
+ }
@@ -0,0 +1,90 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { Card } from "./Card.js";
4
+
5
+ describe("Card", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders a div element", () => {
9
+ const { container } = render(<Card>Content</Card>);
10
+ const el = container.firstElementChild;
11
+ expect(el?.tagName).toBe("DIV");
12
+ });
13
+
14
+ it("renders children", () => {
15
+ const { getByText } = render(<Card>Hello world</Card>);
16
+ expect(getByText("Hello world")).toBeTruthy();
17
+ });
18
+
19
+ // ── Variants ──
20
+
21
+ it("applies elevated variant class by default", () => {
22
+ const { container } = render(<Card>Test</Card>);
23
+ expect(container.firstElementChild?.className).toContain(
24
+ "strand-card--elevated",
25
+ );
26
+ });
27
+
28
+ it("applies outlined variant class", () => {
29
+ const { container } = render(<Card variant="outlined">Test</Card>);
30
+ expect(container.firstElementChild?.className).toContain(
31
+ "strand-card--outlined",
32
+ );
33
+ });
34
+
35
+ it("applies interactive variant class with cursor pointer", () => {
36
+ const { container } = render(<Card variant="interactive">Test</Card>);
37
+ const el = container.firstElementChild;
38
+ expect(el?.className).toContain("strand-card--interactive");
39
+ });
40
+
41
+ // ── Padding ──
42
+
43
+ it("applies md padding class by default", () => {
44
+ const { container } = render(<Card>Test</Card>);
45
+ expect(container.firstElementChild?.className).toContain(
46
+ "strand-card--pad-md",
47
+ );
48
+ });
49
+
50
+ it("applies none padding class", () => {
51
+ const { container } = render(<Card padding="none">Test</Card>);
52
+ expect(container.firstElementChild?.className).toContain(
53
+ "strand-card--pad-none",
54
+ );
55
+ });
56
+
57
+ it("applies sm padding class", () => {
58
+ const { container } = render(<Card padding="sm">Test</Card>);
59
+ expect(container.firstElementChild?.className).toContain(
60
+ "strand-card--pad-sm",
61
+ );
62
+ });
63
+
64
+ it("applies lg padding class", () => {
65
+ const { container } = render(<Card padding="lg">Test</Card>);
66
+ expect(container.firstElementChild?.className).toContain(
67
+ "strand-card--pad-lg",
68
+ );
69
+ });
70
+
71
+ // ── Custom className ──
72
+
73
+ it("merges custom className with component classes", () => {
74
+ const { container } = render(<Card className="custom">Test</Card>);
75
+ const el = container.firstElementChild;
76
+ expect(el?.className).toContain("strand-card");
77
+ expect(el?.className).toContain("custom");
78
+ });
79
+
80
+ // ── Props forwarding ──
81
+
82
+ it("forwards additional props", () => {
83
+ const { container } = render(
84
+ <Card data-testid="my-card" id="card-1">
85
+ Test
86
+ </Card>,
87
+ );
88
+ expect(container.firstElementChild?.getAttribute("id")).toBe("card-1");
89
+ });
90
+ });
@@ -0,0 +1,41 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface CardProps extends JSX.HTMLAttributes<HTMLDivElement> {
7
+ /** Visual style variant */
8
+ variant?: "elevated" | "outlined" | "interactive";
9
+ /** Inner padding */
10
+ padding?: "none" | "sm" | "md" | "lg";
11
+ }
12
+
13
+ export const Card = forwardRef<HTMLDivElement, CardProps>(
14
+ (
15
+ {
16
+ variant = "elevated",
17
+ padding = "md",
18
+ className = "",
19
+ children,
20
+ ...rest
21
+ },
22
+ ref,
23
+ ) => {
24
+ const classes = [
25
+ "strand-card",
26
+ `strand-card--${variant}`,
27
+ `strand-card--pad-${padding}`,
28
+ className,
29
+ ]
30
+ .filter(Boolean)
31
+ .join(" ");
32
+
33
+ return (
34
+ <div ref={ref} className={classes} {...rest}>
35
+ {children}
36
+ </div>
37
+ );
38
+ },
39
+ );
40
+
41
+ Card.displayName = "Card";
@@ -0,0 +1,2 @@
1
+ export { Card } from "./Card.js";
2
+ export type { CardProps } from "./Card.js";
@@ -0,0 +1,97 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Base ── */
4
+ .strand-checkbox {
5
+ display: inline-flex;
6
+ align-items: center;
7
+ gap: var(--strand-space-2);
8
+ cursor: pointer;
9
+ user-select: none;
10
+ font-family: var(--strand-font-sans);
11
+ font-size: var(--strand-text-sm);
12
+ color: var(--strand-gray-900);
13
+ line-height: var(--strand-leading-snug);
14
+ }
15
+
16
+ /* ── Hidden native input ── */
17
+ .strand-checkbox__native {
18
+ position: absolute;
19
+ width: 1px;
20
+ height: 1px;
21
+ padding: 0;
22
+ margin: -1px;
23
+ overflow: hidden;
24
+ clip: rect(0, 0, 0, 0);
25
+ white-space: nowrap;
26
+ border: 0;
27
+ }
28
+
29
+ /* ── Custom visual ── */
30
+ .strand-checkbox__control {
31
+ display: flex;
32
+ align-items: center;
33
+ justify-content: center;
34
+ width: 18px;
35
+ height: 18px;
36
+ border: 1px solid var(--strand-gray-200);
37
+ border-radius: var(--strand-radius-sm);
38
+ background: var(--strand-surface-elevated);
39
+ color: var(--strand-on-blue-primary);
40
+ flex-shrink: 0;
41
+ transition:
42
+ background var(--strand-duration-fast) ease,
43
+ border-color var(--strand-duration-fast) ease,
44
+ box-shadow var(--strand-duration-fast) ease;
45
+ }
46
+
47
+ .strand-checkbox__icon {
48
+ width: 14px;
49
+ height: 14px;
50
+ }
51
+
52
+ /* ── Focus ring ── */
53
+ .strand-checkbox__native:focus-visible ~ .strand-checkbox__control {
54
+ border-color: var(--strand-blue-primary);
55
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
56
+ }
57
+
58
+ /* ── Checked ── */
59
+ .strand-checkbox--checked .strand-checkbox__control {
60
+ background: var(--strand-blue-primary);
61
+ border-color: var(--strand-blue-primary);
62
+ }
63
+
64
+ /* ── Indeterminate ── */
65
+ .strand-checkbox--indeterminate .strand-checkbox__control {
66
+ background: var(--strand-blue-primary);
67
+ border-color: var(--strand-blue-primary);
68
+ }
69
+
70
+ /* ── Hover ── */
71
+ .strand-checkbox:hover:not(.strand-checkbox--disabled) .strand-checkbox__control {
72
+ border-color: var(--strand-blue-indicator);
73
+ }
74
+
75
+ .strand-checkbox--checked:hover:not(.strand-checkbox--disabled) .strand-checkbox__control,
76
+ .strand-checkbox--indeterminate:hover:not(.strand-checkbox--disabled) .strand-checkbox__control {
77
+ background: var(--strand-blue-vivid);
78
+ border-color: var(--strand-blue-vivid);
79
+ }
80
+
81
+ /* ── Label ── */
82
+ .strand-checkbox__label {
83
+ color: var(--strand-gray-900);
84
+ }
85
+
86
+ /* ── Disabled ── */
87
+ .strand-checkbox--disabled {
88
+ opacity: 0.4;
89
+ cursor: not-allowed;
90
+ }
91
+
92
+ /* ── Reduced motion ── */
93
+ @media (prefers-reduced-motion: reduce) {
94
+ .strand-checkbox__control {
95
+ transition: none;
96
+ }
97
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Checkbox } from "./Checkbox.js";
4
+
5
+ describe("Checkbox", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders a checkbox", () => {
9
+ const { getByRole } = render(<Checkbox />);
10
+ expect(getByRole("checkbox")).toBeTruthy();
11
+ });
12
+
13
+ // ── Toggle on click ──
14
+
15
+ it("calls onChange when clicked", () => {
16
+ const onChange = vi.fn();
17
+ const { getByRole } = render(<Checkbox onChange={onChange} />);
18
+ fireEvent.click(getByRole("checkbox"));
19
+ expect(onChange).toHaveBeenCalledTimes(1);
20
+ });
21
+
22
+ // ── Toggle on Space key ──
23
+
24
+ it("toggles on Space key via native input", () => {
25
+ const onChange = vi.fn();
26
+ const { getByRole } = render(<Checkbox onChange={onChange} />);
27
+ const input = getByRole("checkbox");
28
+ fireEvent.keyDown(input, { key: " " });
29
+ expect(onChange).toHaveBeenCalled();
30
+ });
31
+
32
+ // ── Indeterminate state ──
33
+
34
+ it("shows indeterminate state with aria-checked mixed", () => {
35
+ const { getByRole } = render(<Checkbox indeterminate />);
36
+ expect(getByRole("checkbox")).toHaveAttribute("aria-checked", "mixed");
37
+ });
38
+
39
+ it("applies indeterminate class", () => {
40
+ const { container } = render(<Checkbox indeterminate />);
41
+ expect(
42
+ container.querySelector(".strand-checkbox--indeterminate"),
43
+ ).toBeTruthy();
44
+ });
45
+
46
+ // ── Checked state ──
47
+
48
+ it("sets aria-checked true when checked", () => {
49
+ const { getByRole } = render(<Checkbox checked />);
50
+ expect(getByRole("checkbox")).toHaveAttribute("aria-checked", "true");
51
+ });
52
+
53
+ it("sets aria-checked false when unchecked", () => {
54
+ const { getByRole } = render(<Checkbox checked={false} />);
55
+ expect(getByRole("checkbox")).toHaveAttribute("aria-checked", "false");
56
+ });
57
+
58
+ // ── Disabled state ──
59
+
60
+ it("disables the checkbox when disabled prop is set", () => {
61
+ const { getByRole } = render(<Checkbox disabled />);
62
+ expect(getByRole("checkbox")).toBeDisabled();
63
+ });
64
+
65
+ it("does not call onChange when disabled", () => {
66
+ const onChange = vi.fn();
67
+ const { getByRole } = render(<Checkbox disabled onChange={onChange} />);
68
+ fireEvent.click(getByRole("checkbox"));
69
+ expect(onChange).not.toHaveBeenCalled();
70
+ });
71
+
72
+ it("applies disabled class", () => {
73
+ const { container } = render(<Checkbox disabled />);
74
+ expect(container.querySelector(".strand-checkbox--disabled")).toBeTruthy();
75
+ });
76
+
77
+ // ── Label ──
78
+
79
+ it("renders label text", () => {
80
+ const { getByText } = render(<Checkbox label="Accept terms" />);
81
+ expect(getByText("Accept terms")).toBeTruthy();
82
+ });
83
+
84
+ // ── Custom className ──
85
+
86
+ it("merges custom className", () => {
87
+ const { container } = render(<Checkbox className="custom" />);
88
+ expect(container.querySelector(".strand-checkbox")?.className).toContain(
89
+ "custom",
90
+ );
91
+ });
92
+ });