@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,99 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { FormField } from "./FormField.js";
4
+
5
+ describe("FormField", () => {
6
+ it("renders label text", () => {
7
+ const { getByText } = render(
8
+ <FormField label="Name" htmlFor="name">
9
+ <input id="name" />
10
+ </FormField>
11
+ );
12
+ expect(getByText("Name")).toBeTruthy();
13
+ });
14
+
15
+ it("renders children (input element)", () => {
16
+ const { container } = render(
17
+ <FormField label="Name" htmlFor="name">
18
+ <input id="name" />
19
+ </FormField>
20
+ );
21
+ expect(container.querySelector("input#name")).toBeTruthy();
22
+ });
23
+
24
+ it("shows hint text when provided", () => {
25
+ const { getByText } = render(
26
+ <FormField label="Name" htmlFor="name" hint="Enter your full name">
27
+ <input id="name" />
28
+ </FormField>
29
+ );
30
+ expect(getByText("Enter your full name")).toBeTruthy();
31
+ });
32
+
33
+ it("shows error text when provided", () => {
34
+ const { getByText } = render(
35
+ <FormField label="Name" htmlFor="name" error="Name is required">
36
+ <input id="name" />
37
+ </FormField>
38
+ );
39
+ expect(getByText("Name is required")).toBeTruthy();
40
+ });
41
+
42
+ it("shows required indicator when required", () => {
43
+ const { container } = render(
44
+ <FormField label="Name" htmlFor="name" required>
45
+ <input id="name" />
46
+ </FormField>
47
+ );
48
+ const indicator = container.querySelector(".strand-form-field__required");
49
+ expect(indicator).toBeTruthy();
50
+ expect(indicator!.textContent).toBe("*");
51
+ });
52
+
53
+ it("error replaces hint when both are present", () => {
54
+ const { queryByText } = render(
55
+ <FormField
56
+ label="Name"
57
+ htmlFor="name"
58
+ hint="Enter your full name"
59
+ error="Name is required"
60
+ >
61
+ <input id="name" />
62
+ </FormField>
63
+ );
64
+ expect(queryByText("Name is required")).toBeTruthy();
65
+ expect(queryByText("Enter your full name")).toBeNull();
66
+ });
67
+
68
+ it("label has htmlFor attribute", () => {
69
+ const { container } = render(
70
+ <FormField label="Email" htmlFor="email">
71
+ <input id="email" />
72
+ </FormField>
73
+ );
74
+ const label = container.querySelector("label");
75
+ expect(label).toHaveAttribute("for", "email");
76
+ });
77
+
78
+ it("applies error class when error is present", () => {
79
+ const { container } = render(
80
+ <FormField label="Name" htmlFor="name" error="Required">
81
+ <input id="name" />
82
+ </FormField>
83
+ );
84
+ expect(
85
+ container.querySelector(".strand-form-field--error")
86
+ ).toBeTruthy();
87
+ });
88
+
89
+ it("merges custom className", () => {
90
+ const { container } = render(
91
+ <FormField label="Name" htmlFor="name" className="custom">
92
+ <input id="name" />
93
+ </FormField>
94
+ );
95
+ const wrapper = container.querySelector(".strand-form-field");
96
+ expect(wrapper?.className).toContain("strand-form-field");
97
+ expect(wrapper?.className).toContain("custom");
98
+ });
99
+ });
@@ -0,0 +1,79 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { ComponentChildren } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface FormFieldProps {
7
+ /** Label text */
8
+ label: string;
9
+ /** Associates the label with a form control */
10
+ htmlFor: string;
11
+ /** Hint text displayed below the input */
12
+ hint?: string;
13
+ /** Error text displayed below the input (replaces hint) */
14
+ error?: string;
15
+ /** Show required indicator */
16
+ required?: boolean;
17
+ /** Additional CSS classes */
18
+ className?: string;
19
+ /** The wrapped form control */
20
+ children: ComponentChildren;
21
+ }
22
+
23
+ export const FormField = forwardRef<HTMLDivElement, FormFieldProps>(
24
+ (
25
+ {
26
+ label,
27
+ htmlFor,
28
+ hint,
29
+ error,
30
+ required = false,
31
+ className = "",
32
+ children,
33
+ },
34
+ ref,
35
+ ) => {
36
+ const classes = [
37
+ "strand-form-field",
38
+ error && "strand-form-field--error",
39
+ className,
40
+ ]
41
+ .filter(Boolean)
42
+ .join(" ");
43
+
44
+ const messageId = error
45
+ ? `${htmlFor}-error`
46
+ : hint
47
+ ? `${htmlFor}-hint`
48
+ : undefined;
49
+
50
+ return (
51
+ <div ref={ref} className={classes}>
52
+ <label className="strand-form-field__label" htmlFor={htmlFor}>
53
+ {label}
54
+ {required && (
55
+ <span className="strand-form-field__required" aria-hidden="true">
56
+ *
57
+ </span>
58
+ )}
59
+ </label>
60
+ <div className="strand-form-field__control">{children}</div>
61
+ {error ? (
62
+ <p
63
+ className="strand-form-field__error"
64
+ id={`${htmlFor}-error`}
65
+ role="alert"
66
+ >
67
+ {error}
68
+ </p>
69
+ ) : hint ? (
70
+ <p className="strand-form-field__hint" id={`${htmlFor}-hint`}>
71
+ {hint}
72
+ </p>
73
+ ) : null}
74
+ </div>
75
+ );
76
+ },
77
+ );
78
+
79
+ FormField.displayName = "FormField";
@@ -0,0 +1,2 @@
1
+ export { FormField } from "./FormField.js";
2
+ export type { FormFieldProps } from "./FormField.js";
@@ -0,0 +1,6 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Base ── */
4
+ .strand-grid {
5
+ display: grid;
6
+ }
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { Grid } from "./Grid.js";
4
+
5
+ describe("Grid", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders a div element", () => {
9
+ const { container } = render(<Grid>content</Grid>);
10
+ expect(container.firstElementChild?.tagName).toBe("DIV");
11
+ });
12
+
13
+ it("renders children in grid", () => {
14
+ const { getByText } = render(
15
+ <Grid>
16
+ <div>Item 1</div>
17
+ <div>Item 2</div>
18
+ <div>Item 3</div>
19
+ </Grid>,
20
+ );
21
+ expect(getByText("Item 1")).toBeTruthy();
22
+ expect(getByText("Item 2")).toBeTruthy();
23
+ expect(getByText("Item 3")).toBeTruthy();
24
+ });
25
+
26
+ // ── Display ──
27
+
28
+ it("has display grid class", () => {
29
+ const { container } = render(<Grid>content</Grid>);
30
+ expect(container.firstElementChild?.className).toContain("strand-grid");
31
+ });
32
+
33
+ // ── Columns ──
34
+
35
+ it("defaults to 1 column in inline style", () => {
36
+ const { container } = render(<Grid>content</Grid>);
37
+ const el = container.firstElementChild as HTMLElement;
38
+ expect(el.style.gridTemplateColumns).toBe("repeat(1, 1fr)");
39
+ });
40
+
41
+ it("applies custom column count in inline style", () => {
42
+ const { container } = render(<Grid columns={3}>content</Grid>);
43
+ const el = container.firstElementChild as HTMLElement;
44
+ expect(el.style.gridTemplateColumns).toBe("repeat(3, 1fr)");
45
+ });
46
+
47
+ it("applies 4-column layout", () => {
48
+ const { container } = render(<Grid columns={4}>content</Grid>);
49
+ const el = container.firstElementChild as HTMLElement;
50
+ expect(el.style.gridTemplateColumns).toBe("repeat(4, 1fr)");
51
+ });
52
+
53
+ // ── Gap ──
54
+
55
+ it("applies default gap as inline style", () => {
56
+ const { container } = render(<Grid>content</Grid>);
57
+ const el = container.firstElementChild as HTMLElement;
58
+ expect(el.style.gap).toBe("var(--strand-space-4)");
59
+ });
60
+
61
+ it("applies custom gap as inline style", () => {
62
+ const { container } = render(<Grid gap={8}>content</Grid>);
63
+ const el = container.firstElementChild as HTMLElement;
64
+ expect(el.style.gap).toBe("var(--strand-space-8)");
65
+ });
66
+
67
+ // ── Custom className ──
68
+
69
+ it("merges custom className", () => {
70
+ const { container } = render(<Grid className="custom">content</Grid>);
71
+ const el = container.firstElementChild;
72
+ expect(el?.className).toContain("strand-grid");
73
+ expect(el?.className).toContain("custom");
74
+ });
75
+
76
+ // ── Props forwarding ──
77
+
78
+ it("forwards additional props", () => {
79
+ const { container } = render(
80
+ <Grid data-testid="my-grid" id="g1">
81
+ content
82
+ </Grid>,
83
+ );
84
+ expect(container.firstElementChild).toHaveAttribute("id", "g1");
85
+ });
86
+ });
@@ -0,0 +1,45 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface GridProps extends JSX.HTMLAttributes<HTMLDivElement> {
7
+ /** Number of equal-width columns */
8
+ columns?: number;
9
+ /** Gap between items, maps to --strand-space-{n} */
10
+ gap?: number;
11
+ }
12
+
13
+ export const Grid = forwardRef<HTMLDivElement, GridProps>(
14
+ (
15
+ {
16
+ columns = 1,
17
+ gap = 4,
18
+ className = "",
19
+ style,
20
+ children,
21
+ ...rest
22
+ },
23
+ ref,
24
+ ) => {
25
+ const classes = ["strand-grid", className].filter(Boolean).join(" ");
26
+
27
+ const inlineStyle: Record<string, string> = {
28
+ gridTemplateColumns: `repeat(${columns}, 1fr)`,
29
+ gap: `var(--strand-space-${gap})`,
30
+ };
31
+
32
+ return (
33
+ <div
34
+ ref={ref}
35
+ className={classes}
36
+ style={{ ...inlineStyle, ...(style as Record<string, string>) }}
37
+ {...rest}
38
+ >
39
+ {children}
40
+ </div>
41
+ );
42
+ },
43
+ );
44
+
45
+ Grid.displayName = "Grid";
@@ -0,0 +1,2 @@
1
+ export { Grid } from "./Grid.js";
2
+ export type { GridProps } from "./Grid.js";
@@ -0,0 +1,80 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ .strand-input {
4
+ position: relative;
5
+ display: flex;
6
+ align-items: center;
7
+ background: var(--strand-surface-elevated);
8
+ border: 1px solid var(--strand-gray-200);
9
+ border-radius: var(--strand-radius-md);
10
+ transition:
11
+ border-color var(--strand-duration-fast) ease,
12
+ box-shadow var(--strand-duration-fast) ease;
13
+ }
14
+
15
+ .strand-input:focus-within {
16
+ border-color: var(--strand-blue-primary);
17
+ box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
18
+ }
19
+
20
+ /* ── Field ── */
21
+ .strand-input__field {
22
+ flex: 1;
23
+ width: 100%;
24
+ padding: var(--strand-space-3) var(--strand-space-4);
25
+ background: transparent;
26
+ border: none;
27
+ font-family: var(--strand-font-sans);
28
+ font-size: var(--strand-text-base);
29
+ color: var(--strand-gray-900);
30
+ outline: none;
31
+ }
32
+
33
+ .strand-input__field::placeholder {
34
+ color: var(--strand-gray-400);
35
+ }
36
+
37
+ /* ── Addons ── */
38
+ .strand-input__leading,
39
+ .strand-input__trailing {
40
+ display: flex;
41
+ align-items: center;
42
+ color: var(--strand-gray-500);
43
+ font-size: var(--strand-text-sm);
44
+ }
45
+
46
+ .strand-input__leading {
47
+ padding-left: var(--strand-space-3);
48
+ }
49
+
50
+ .strand-input__trailing {
51
+ padding-right: var(--strand-space-3);
52
+ }
53
+
54
+ .strand-input--has-leading .strand-input__field {
55
+ padding-left: var(--strand-space-2);
56
+ }
57
+
58
+ .strand-input--has-trailing .strand-input__field {
59
+ padding-right: var(--strand-space-2);
60
+ }
61
+
62
+ /* ── Error ── */
63
+ .strand-input--error {
64
+ border-color: var(--strand-red-alert);
65
+ }
66
+
67
+ .strand-input--error:focus-within {
68
+ border-color: var(--strand-red-alert);
69
+ box-shadow: 0 0 0 3px rgba(239, 68, 68, 0.1);
70
+ }
71
+
72
+ /* ── Disabled ── */
73
+ .strand-input--disabled {
74
+ opacity: 0.4;
75
+ cursor: not-allowed;
76
+ }
77
+
78
+ .strand-input--disabled .strand-input__field {
79
+ cursor: not-allowed;
80
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Input } from "./Input.js";
4
+
5
+ describe("Input", () => {
6
+ it("renders an input element", () => {
7
+ const { getByRole } = render(<Input aria-label="Name" />);
8
+ expect(getByRole("textbox")).toBeTruthy();
9
+ });
10
+
11
+ it("defaults to type text", () => {
12
+ const { getByRole } = render(<Input aria-label="Name" />);
13
+ expect(getByRole("textbox")).toHaveAttribute("type", "text");
14
+ });
15
+
16
+ it("accepts type email", () => {
17
+ const { container } = render(<Input type="email" aria-label="Email" />);
18
+ expect(container.querySelector("input")).toHaveAttribute("type", "email");
19
+ });
20
+
21
+ it("accepts type password", () => {
22
+ const { container } = render(<Input type="password" aria-label="Password" />);
23
+ expect(container.querySelector("input")).toHaveAttribute("type", "password");
24
+ });
25
+
26
+ it("accepts type number", () => {
27
+ const { container } = render(<Input type="number" aria-label="Count" />);
28
+ expect(container.querySelector("input")).toHaveAttribute("type", "number");
29
+ });
30
+
31
+ it("accepts type search", () => {
32
+ const { getByRole } = render(<Input type="search" aria-label="Search" />);
33
+ expect(getByRole("searchbox")).toBeTruthy();
34
+ });
35
+
36
+ it("renders placeholder text", () => {
37
+ const { getByPlaceholderText } = render(
38
+ <Input placeholder="Enter name" aria-label="Name" />
39
+ );
40
+ expect(getByPlaceholderText("Enter name")).toBeTruthy();
41
+ });
42
+
43
+ it("calls onChange when value changes", () => {
44
+ const onChange = vi.fn();
45
+ const { getByRole } = render(
46
+ <Input aria-label="Name" onChange={onChange} />
47
+ );
48
+ fireEvent.input(getByRole("textbox"), { target: { value: "hello" } });
49
+ expect(onChange).toHaveBeenCalled();
50
+ });
51
+
52
+ it("sets disabled attribute", () => {
53
+ const { getByRole } = render(<Input aria-label="Name" disabled />);
54
+ expect(getByRole("textbox")).toBeDisabled();
55
+ });
56
+
57
+ it("applies error class when error prop is true", () => {
58
+ const { container } = render(<Input aria-label="Name" error />);
59
+ expect(container.querySelector(".strand-input--error")).toBeTruthy();
60
+ });
61
+
62
+ it("sets aria-invalid when error is true", () => {
63
+ const { getByRole } = render(<Input aria-label="Name" error />);
64
+ expect(getByRole("textbox")).toHaveAttribute("aria-invalid", "true");
65
+ });
66
+
67
+ it("renders leading addon", () => {
68
+ const { container } = render(
69
+ <Input aria-label="Amount" leadingAddon={<span>$</span>} />
70
+ );
71
+ expect(container.querySelector(".strand-input__leading")).toBeTruthy();
72
+ });
73
+
74
+ it("renders trailing addon", () => {
75
+ const { container } = render(
76
+ <Input aria-label="Weight" trailingAddon={<span>kg</span>} />
77
+ );
78
+ expect(container.querySelector(".strand-input__trailing")).toBeTruthy();
79
+ });
80
+
81
+ it("merges custom className", () => {
82
+ const { container } = render(
83
+ <Input aria-label="Name" className="custom" />
84
+ );
85
+ expect(container.querySelector(".strand-input")?.className).toContain("custom");
86
+ });
87
+
88
+ it("forwards ref to input element", () => {
89
+ let inputEl: HTMLInputElement | null = null;
90
+ render(
91
+ <Input aria-label="Name" ref={(el: HTMLInputElement | null) => { inputEl = el; }} />
92
+ );
93
+ expect(inputEl).toBeInstanceOf(HTMLInputElement);
94
+ });
95
+ });
@@ -0,0 +1,69 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX, ComponentChildren } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface InputProps
7
+ extends Omit<JSX.HTMLAttributes<HTMLInputElement>, "size"> {
8
+ /** Input type */
9
+ type?: "text" | "email" | "password" | "search" | "number";
10
+ /** Show error styling */
11
+ error?: boolean;
12
+ /** Element rendered before the input */
13
+ leadingAddon?: ComponentChildren;
14
+ /** Element rendered after the input */
15
+ trailingAddon?: ComponentChildren;
16
+ /** Disabled state */
17
+ disabled?: boolean;
18
+ }
19
+
20
+ export const Input = forwardRef<HTMLInputElement, InputProps>(
21
+ (
22
+ {
23
+ type = "text",
24
+ error = false,
25
+ leadingAddon,
26
+ trailingAddon,
27
+ className = "",
28
+ disabled,
29
+ ...rest
30
+ },
31
+ ref,
32
+ ) => {
33
+ const wrapperClasses = [
34
+ "strand-input",
35
+ error && "strand-input--error",
36
+ disabled && "strand-input--disabled",
37
+ leadingAddon && "strand-input--has-leading",
38
+ trailingAddon && "strand-input--has-trailing",
39
+ className,
40
+ ]
41
+ .filter(Boolean)
42
+ .join(" ");
43
+
44
+ return (
45
+ <div className={wrapperClasses}>
46
+ {leadingAddon && (
47
+ <span className="strand-input__leading" aria-hidden="true">
48
+ {leadingAddon}
49
+ </span>
50
+ )}
51
+ <input
52
+ ref={ref}
53
+ type={type}
54
+ className="strand-input__field"
55
+ disabled={disabled}
56
+ aria-invalid={error ? "true" : undefined}
57
+ {...rest}
58
+ />
59
+ {trailingAddon && (
60
+ <span className="strand-input__trailing" aria-hidden="true">
61
+ {trailingAddon}
62
+ </span>
63
+ )}
64
+ </div>
65
+ );
66
+ },
67
+ );
68
+
69
+ Input.displayName = "Input";
@@ -0,0 +1,2 @@
1
+ export { Input } from "./Input.js";
2
+ export type { InputProps } from "./Input.js";
@@ -0,0 +1,24 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ .strand-link {
4
+ color: var(--strand-blue-primary);
5
+ text-decoration: none;
6
+ font-family: var(--strand-font-sans);
7
+ background-image: linear-gradient(currentColor, currentColor);
8
+ background-position: 0% 100%;
9
+ background-repeat: no-repeat;
10
+ background-size: 0% 1px;
11
+ transition: background-size var(--strand-duration-normal) var(--strand-ease-out-expo);
12
+ cursor: pointer;
13
+ }
14
+
15
+ .strand-link:hover {
16
+ background-size: 100% 1px;
17
+ }
18
+
19
+ /* ── Reduced motion ── */
20
+ @media (prefers-reduced-motion: reduce) {
21
+ .strand-link {
22
+ transition: none;
23
+ }
24
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { Link } from "./Link.js";
4
+
5
+ describe("Link", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders an anchor element", () => {
9
+ const { getByRole } = render(<Link href="/about">About</Link>);
10
+ expect(getByRole("link").tagName).toBe("A");
11
+ });
12
+
13
+ it("has the correct href attribute", () => {
14
+ const { getByRole } = render(<Link href="/about">About</Link>);
15
+ expect(getByRole("link")).toHaveAttribute("href", "/about");
16
+ });
17
+
18
+ it("applies blue color class", () => {
19
+ const { getByRole } = render(<Link href="/about">About</Link>);
20
+ expect(getByRole("link").className).toContain("strand-link");
21
+ });
22
+
23
+ it("renders children text", () => {
24
+ const { getByRole } = render(<Link href="/about">About Us</Link>);
25
+ expect(getByRole("link")).toHaveTextContent("About Us");
26
+ });
27
+
28
+ // ── External ──
29
+
30
+ it("adds target _blank when external", () => {
31
+ const { getByRole } = render(
32
+ <Link href="https://example.com" external>
33
+ External
34
+ </Link>,
35
+ );
36
+ expect(getByRole("link")).toHaveAttribute("target", "_blank");
37
+ });
38
+
39
+ it("adds rel noopener noreferrer when external", () => {
40
+ const { getByRole } = render(
41
+ <Link href="https://example.com" external>
42
+ External
43
+ </Link>,
44
+ );
45
+ expect(getByRole("link")).toHaveAttribute("rel", "noopener noreferrer");
46
+ });
47
+
48
+ it("does not add target or rel when not external", () => {
49
+ const { getByRole } = render(<Link href="/about">About</Link>);
50
+ const link = getByRole("link");
51
+ expect(link).not.toHaveAttribute("target");
52
+ expect(link).not.toHaveAttribute("rel");
53
+ });
54
+
55
+ // ── Custom className ──
56
+
57
+ it("merges custom className with component class", () => {
58
+ const { getByRole } = render(
59
+ <Link href="/about" className="custom">
60
+ About
61
+ </Link>,
62
+ );
63
+ const link = getByRole("link");
64
+ expect(link.className).toContain("strand-link");
65
+ expect(link.className).toContain("custom");
66
+ });
67
+
68
+ // ── No text-decoration ──
69
+
70
+ it("does not have text-decoration class (relies on CSS reset)", () => {
71
+ const { getByRole } = render(<Link href="/about">About</Link>);
72
+ expect(getByRole("link").className).not.toContain("text-decoration");
73
+ });
74
+
75
+ // ── Accessibility ──
76
+
77
+ it("forwards additional props", () => {
78
+ const { getByRole } = render(
79
+ <Link href="/about" aria-label="Go to about page">
80
+ About
81
+ </Link>,
82
+ );
83
+ expect(getByRole("link")).toHaveAttribute(
84
+ "aria-label",
85
+ "Go to about page",
86
+ );
87
+ });
88
+ });