@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,2 @@
1
+ export { Stack } from "./Stack.js";
2
+ export type { StackProps } from "./Stack.js";
@@ -0,0 +1,94 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Base ── */
4
+ .strand-switch {
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
+ /* ── Track ── */
17
+ .strand-switch__track {
18
+ position: relative;
19
+ display: inline-flex;
20
+ align-items: center;
21
+ width: 40px;
22
+ height: 22px;
23
+ padding: 0;
24
+ border: 1px solid var(--strand-gray-200);
25
+ border-radius: var(--strand-radius-full);
26
+ background: var(--strand-gray-200);
27
+ cursor: pointer;
28
+ flex-shrink: 0;
29
+ transition:
30
+ background var(--strand-duration-fast) var(--strand-ease-out-quart),
31
+ border-color var(--strand-duration-fast) var(--strand-ease-out-quart),
32
+ box-shadow var(--strand-duration-fast) var(--strand-ease-out-quart);
33
+ }
34
+
35
+ .strand-switch__track:focus-visible {
36
+ border-color: var(--strand-blue-primary);
37
+ box-shadow: var(--strand-focus-ring);
38
+ outline: none;
39
+ }
40
+
41
+ /* ── Thumb ── */
42
+ .strand-switch__thumb {
43
+ position: absolute;
44
+ left: 2px;
45
+ width: 16px;
46
+ height: 16px;
47
+ border-radius: var(--strand-radius-full);
48
+ background: var(--strand-surface-elevated);
49
+ box-shadow: var(--strand-elevation-1);
50
+ transition: transform var(--strand-duration-fast) var(--strand-ease-out-expo);
51
+ }
52
+
53
+ /* ── Checked ── */
54
+ .strand-switch--checked .strand-switch__track {
55
+ background: var(--strand-blue-primary);
56
+ border-color: var(--strand-blue-primary);
57
+ }
58
+
59
+ .strand-switch--checked .strand-switch__thumb {
60
+ transform: translateX(18px);
61
+ }
62
+
63
+ /* ── Hover ── */
64
+ .strand-switch:hover:not(.strand-switch--disabled) .strand-switch__track {
65
+ border-color: var(--strand-blue-indicator);
66
+ }
67
+
68
+ .strand-switch--checked:hover:not(.strand-switch--disabled) .strand-switch__track {
69
+ background: var(--strand-blue-vivid);
70
+ border-color: var(--strand-blue-vivid);
71
+ }
72
+
73
+ /* ── Label ── */
74
+ .strand-switch__label {
75
+ color: var(--strand-gray-900);
76
+ }
77
+
78
+ /* ── Disabled ── */
79
+ .strand-switch--disabled {
80
+ opacity: 0.4;
81
+ cursor: not-allowed;
82
+ }
83
+
84
+ .strand-switch--disabled .strand-switch__track {
85
+ cursor: not-allowed;
86
+ }
87
+
88
+ /* ── Reduced motion ── */
89
+ @media (prefers-reduced-motion: reduce) {
90
+ .strand-switch__track,
91
+ .strand-switch__thumb {
92
+ transition: none;
93
+ }
94
+ }
@@ -0,0 +1,98 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Switch } from "./Switch.js";
4
+
5
+ describe("Switch", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders as switch role", () => {
9
+ const { getByRole } = render(<Switch />);
10
+ expect(getByRole("switch")).toBeTruthy();
11
+ });
12
+
13
+ // ── Toggle on click ──
14
+
15
+ it("calls onChange with toggled value when clicked", () => {
16
+ const onChange = vi.fn();
17
+ const { getByRole } = render(<Switch onChange={onChange} />);
18
+ fireEvent.click(getByRole("switch"));
19
+ expect(onChange).toHaveBeenCalledWith(true);
20
+ });
21
+
22
+ it("calls onChange with false when checked switch is clicked", () => {
23
+ const onChange = vi.fn();
24
+ const { getByRole } = render(<Switch checked onChange={onChange} />);
25
+ fireEvent.click(getByRole("switch"));
26
+ expect(onChange).toHaveBeenCalledWith(false);
27
+ });
28
+
29
+ // ── Toggle on Space key ──
30
+
31
+ it("toggles on Space key", () => {
32
+ const onChange = vi.fn();
33
+ const { getByRole } = render(<Switch onChange={onChange} />);
34
+ fireEvent.keyDown(getByRole("switch"), { key: " " });
35
+ expect(onChange).toHaveBeenCalledWith(true);
36
+ });
37
+
38
+ // ── Toggle on Enter key ──
39
+
40
+ it("toggles on Enter key", () => {
41
+ const onChange = vi.fn();
42
+ const { getByRole } = render(<Switch onChange={onChange} />);
43
+ fireEvent.keyDown(getByRole("switch"), { key: "Enter" });
44
+ expect(onChange).toHaveBeenCalledWith(true);
45
+ });
46
+
47
+ // ── Checked state ──
48
+
49
+ it("sets aria-checked true when checked", () => {
50
+ const { getByRole } = render(<Switch checked />);
51
+ expect(getByRole("switch")).toHaveAttribute("aria-checked", "true");
52
+ });
53
+
54
+ it("sets aria-checked false when unchecked", () => {
55
+ const { getByRole } = render(<Switch checked={false} />);
56
+ expect(getByRole("switch")).toHaveAttribute("aria-checked", "false");
57
+ });
58
+
59
+ it("applies checked class", () => {
60
+ const { container } = render(<Switch checked />);
61
+ expect(container.querySelector(".strand-switch--checked")).toBeTruthy();
62
+ });
63
+
64
+ // ── Disabled state ──
65
+
66
+ it("disables the switch when disabled prop is set", () => {
67
+ const { getByRole } = render(<Switch disabled />);
68
+ expect(getByRole("switch")).toBeDisabled();
69
+ });
70
+
71
+ it("does not call onChange when disabled", () => {
72
+ const onChange = vi.fn();
73
+ const { getByRole } = render(<Switch disabled onChange={onChange} />);
74
+ fireEvent.click(getByRole("switch"));
75
+ expect(onChange).not.toHaveBeenCalled();
76
+ });
77
+
78
+ it("applies disabled class", () => {
79
+ const { container } = render(<Switch disabled />);
80
+ expect(container.querySelector(".strand-switch--disabled")).toBeTruthy();
81
+ });
82
+
83
+ // ── Inline label ──
84
+
85
+ it("renders inline label text", () => {
86
+ const { getByText } = render(<Switch label="Dark mode" />);
87
+ expect(getByText("Dark mode")).toBeTruthy();
88
+ });
89
+
90
+ // ── Custom className ──
91
+
92
+ it("merges custom className", () => {
93
+ const { container } = render(<Switch className="custom" />);
94
+ expect(container.querySelector(".strand-switch")?.className).toContain(
95
+ "custom",
96
+ );
97
+ });
98
+ });
@@ -0,0 +1,80 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface SwitchProps
7
+ extends Omit<
8
+ JSX.HTMLAttributes<HTMLButtonElement>,
9
+ "checked" | "onChange" | "label" | "type" | "role"
10
+ > {
11
+ /** Controlled checked state */
12
+ checked?: boolean;
13
+ /** Change handler */
14
+ onChange?: (checked: boolean) => void;
15
+ /** Disabled state */
16
+ disabled?: boolean;
17
+ /** Inline label text */
18
+ label?: string;
19
+ /** Additional CSS class */
20
+ className?: string;
21
+ }
22
+
23
+ export const Switch = forwardRef<HTMLButtonElement, SwitchProps>(
24
+ (
25
+ {
26
+ checked = false,
27
+ onChange,
28
+ disabled = false,
29
+ label,
30
+ className = "",
31
+ ...rest
32
+ },
33
+ ref,
34
+ ) => {
35
+ const classes = [
36
+ "strand-switch",
37
+ checked && "strand-switch--checked",
38
+ disabled && "strand-switch--disabled",
39
+ className,
40
+ ]
41
+ .filter(Boolean)
42
+ .join(" ");
43
+
44
+ const handleClick = () => {
45
+ if (!disabled && onChange) {
46
+ onChange(!checked);
47
+ }
48
+ };
49
+
50
+ const handleKeyDown = (e: KeyboardEvent) => {
51
+ if ((e.key === " " || e.key === "Enter") && !disabled) {
52
+ e.preventDefault();
53
+ if (onChange) {
54
+ onChange(!checked);
55
+ }
56
+ }
57
+ };
58
+
59
+ return (
60
+ <label className={classes}>
61
+ <button
62
+ ref={ref}
63
+ type="button"
64
+ role="switch"
65
+ className="strand-switch__track"
66
+ aria-checked={checked ? "true" : "false"}
67
+ disabled={disabled}
68
+ onClick={handleClick}
69
+ onKeyDown={handleKeyDown}
70
+ {...rest}
71
+ >
72
+ <span className="strand-switch__thumb" aria-hidden="true" />
73
+ </button>
74
+ {label && <span className="strand-switch__label">{label}</span>}
75
+ </label>
76
+ );
77
+ },
78
+ );
79
+
80
+ Switch.displayName = "Switch";
@@ -0,0 +1,2 @@
1
+ export { Switch } from "./Switch.js";
2
+ export type { SwitchProps } from "./Switch.js";
@@ -0,0 +1,83 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Wrapper (responsive scroll) ── */
4
+ .strand-table-wrapper {
5
+ overflow-x: auto;
6
+ -webkit-overflow-scrolling: touch;
7
+ }
8
+
9
+ /* ── Table ── */
10
+ .strand-table {
11
+ width: 100%;
12
+ border-collapse: collapse;
13
+ border-spacing: 0;
14
+ }
15
+
16
+ /* ── Header ── */
17
+ .strand-table__th {
18
+ padding: var(--strand-space-3) var(--strand-space-4);
19
+ font-family: var(--strand-font-mono);
20
+ font-size: var(--strand-text-xs);
21
+ font-weight: var(--strand-weight-medium);
22
+ letter-spacing: var(--strand-tracking-widest);
23
+ text-transform: uppercase;
24
+ color: var(--strand-gray-500);
25
+ text-align: left;
26
+ border-bottom: 1px solid var(--strand-gray-200);
27
+ white-space: nowrap;
28
+ }
29
+
30
+ /* ── Sort button ── */
31
+ .strand-table__sort-btn {
32
+ display: inline-flex;
33
+ align-items: center;
34
+ gap: var(--strand-space-1);
35
+ padding: 0;
36
+ border: none;
37
+ background: none;
38
+ font: inherit;
39
+ color: inherit;
40
+ letter-spacing: inherit;
41
+ text-transform: inherit;
42
+ cursor: pointer;
43
+ white-space: nowrap;
44
+ }
45
+
46
+ .strand-table__sort-btn:hover {
47
+ color: var(--strand-gray-600);
48
+ }
49
+
50
+ .strand-table__sort-btn:focus-visible {
51
+ outline: 2px solid var(--strand-blue-primary);
52
+ outline-offset: 2px;
53
+ }
54
+
55
+ .strand-table__sort-indicator {
56
+ font-size: var(--strand-text-xs);
57
+ opacity: 0.6;
58
+ }
59
+
60
+ /* ── Body ── */
61
+ .strand-table__td {
62
+ padding: var(--strand-space-3) var(--strand-space-4);
63
+ font-family: var(--strand-font-sans);
64
+ font-size: var(--strand-text-sm);
65
+ color: var(--strand-gray-600);
66
+ border-bottom: 1px solid var(--strand-gray-200);
67
+ }
68
+
69
+ /* ── Row hover ── */
70
+ .strand-table__row {
71
+ transition: background var(--strand-duration-fast) var(--strand-ease-out-quart);
72
+ }
73
+
74
+ .strand-table__row:hover {
75
+ background: var(--strand-blue-glow);
76
+ }
77
+
78
+ /* ── Reduced motion ── */
79
+ @media (prefers-reduced-motion: reduce) {
80
+ .strand-table__row {
81
+ transition: none;
82
+ }
83
+ }
@@ -0,0 +1,134 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Table } from "./Table.js";
4
+
5
+ const columns = [
6
+ { key: "name", header: "Name", sortable: true },
7
+ { key: "role", header: "Role" },
8
+ { key: "status", header: "Status", sortable: true },
9
+ ];
10
+
11
+ const data = [
12
+ { name: "Alice", role: "Engineer", status: "Active" },
13
+ { name: "Bob", role: "Designer", status: "Away" },
14
+ ];
15
+
16
+ describe("Table", () => {
17
+ // ── Rendering ──
18
+
19
+ it("renders a table element", () => {
20
+ const { container } = render(<Table columns={columns} data={data} />);
21
+ expect(container.querySelector("table")).toBeTruthy();
22
+ });
23
+
24
+ it("renders column headers", () => {
25
+ const { getByText } = render(<Table columns={columns} data={data} />);
26
+ expect(getByText("Name")).toBeTruthy();
27
+ expect(getByText("Role")).toBeTruthy();
28
+ expect(getByText("Status")).toBeTruthy();
29
+ });
30
+
31
+ it("renders data rows", () => {
32
+ const { getByText } = render(<Table columns={columns} data={data} />);
33
+ expect(getByText("Alice")).toBeTruthy();
34
+ expect(getByText("Bob")).toBeTruthy();
35
+ });
36
+
37
+ it("renders correct number of cells", () => {
38
+ const { container } = render(<Table columns={columns} data={data} />);
39
+ const cells = container.querySelectorAll(".strand-table__td");
40
+ // 2 rows x 3 columns = 6 cells
41
+ expect(cells.length).toBe(6);
42
+ });
43
+
44
+ it("renders correct number of header cells", () => {
45
+ const { container } = render(<Table columns={columns} data={data} />);
46
+ const headers = container.querySelectorAll(".strand-table__th");
47
+ expect(headers.length).toBe(3);
48
+ });
49
+
50
+ // ── Sorting ──
51
+
52
+ it("renders sort button for sortable columns", () => {
53
+ const { container } = render(<Table columns={columns} data={data} />);
54
+ const sortButtons = container.querySelectorAll(".strand-table__sort-btn");
55
+ // "Name" and "Status" are sortable
56
+ expect(sortButtons.length).toBe(2);
57
+ });
58
+
59
+ it("does not render sort button for non-sortable columns", () => {
60
+ const nonSortable = [{ key: "role", header: "Role" }];
61
+ const { container } = render(
62
+ <Table columns={nonSortable} data={data} />,
63
+ );
64
+ const sortButtons = container.querySelectorAll(".strand-table__sort-btn");
65
+ expect(sortButtons.length).toBe(0);
66
+ });
67
+
68
+ it("calls onSort with key and asc direction on first click", () => {
69
+ const onSort = vi.fn();
70
+ const { container } = render(
71
+ <Table columns={columns} data={data} onSort={onSort} />,
72
+ );
73
+ const sortButtons = container.querySelectorAll(".strand-table__sort-btn");
74
+ fireEvent.click(sortButtons[0]);
75
+ expect(onSort).toHaveBeenCalledWith("name", "asc");
76
+ });
77
+
78
+ it("toggles sort direction on second click of same column", () => {
79
+ const onSort = vi.fn();
80
+ const { container } = render(
81
+ <Table columns={columns} data={data} onSort={onSort} />,
82
+ );
83
+ const sortButtons = container.querySelectorAll(".strand-table__sort-btn");
84
+ fireEvent.click(sortButtons[0]); // asc
85
+ fireEvent.click(sortButtons[0]); // desc
86
+ expect(onSort).toHaveBeenLastCalledWith("name", "desc");
87
+ });
88
+
89
+ // ── Responsive ──
90
+
91
+ it("wraps table in overflow-x scroll container", () => {
92
+ const { container } = render(<Table columns={columns} data={data} />);
93
+ const wrapper = container.querySelector(".strand-table-wrapper");
94
+ expect(wrapper).toBeTruthy();
95
+ });
96
+
97
+ // ── Custom className ──
98
+
99
+ it("merges custom className with wrapper classes", () => {
100
+ const { container } = render(
101
+ <Table columns={columns} data={data} className="custom" />,
102
+ );
103
+ const wrapper = container.querySelector(".strand-table-wrapper");
104
+ expect(wrapper?.className).toContain("strand-table-wrapper");
105
+ expect(wrapper?.className).toContain("custom");
106
+ });
107
+
108
+ // ── Empty state ──
109
+
110
+ it("renders empty tbody when data is empty", () => {
111
+ const { container } = render(<Table columns={columns} data={[]} />);
112
+ const rows = container.querySelectorAll(".strand-table__row");
113
+ expect(rows.length).toBe(0);
114
+ });
115
+
116
+ // ── Column width ──
117
+
118
+ it("applies width style to column headers", () => {
119
+ const cols = [{ key: "name", header: "Name", width: "200px" }];
120
+ const { container } = render(<Table columns={cols} data={[]} />);
121
+ const th = container.querySelector(".strand-table__th") as HTMLElement;
122
+ expect(th?.style.width).toBe("200px");
123
+ });
124
+
125
+ // ── Sort indicator ──
126
+
127
+ it("shows sort indicator on sortable columns", () => {
128
+ const { container } = render(<Table columns={columns} data={data} />);
129
+ const indicators = container.querySelectorAll(
130
+ ".strand-table__sort-indicator",
131
+ );
132
+ expect(indicators.length).toBe(2);
133
+ });
134
+ });
@@ -0,0 +1,102 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+ import { useState, useCallback } from "preact/hooks";
6
+
7
+ export interface TableColumn {
8
+ /** Unique key matching the data field */
9
+ key: string;
10
+ /** Display header text */
11
+ header: string;
12
+ /** Whether the column is sortable */
13
+ sortable?: boolean;
14
+ /** Optional fixed width */
15
+ width?: string;
16
+ }
17
+
18
+ export interface TableProps
19
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "data"> {
20
+ /** Column definitions */
21
+ columns: TableColumn[];
22
+ /** Row data */
23
+ data: Array<Record<string, any>>;
24
+ /** Called when a sortable column header is clicked */
25
+ onSort?: (key: string, direction: "asc" | "desc") => void;
26
+ }
27
+
28
+ export const Table = forwardRef<HTMLDivElement, TableProps>(
29
+ ({ columns, data, onSort, className = "", ...rest }, ref) => {
30
+ const [sortKey, setSortKey] = useState<string | null>(null);
31
+ const [sortDirection, setSortDirection] = useState<"asc" | "desc">("asc");
32
+
33
+ const handleSort = useCallback(
34
+ (key: string) => {
35
+ const nextDirection =
36
+ sortKey === key && sortDirection === "asc" ? "desc" : "asc";
37
+ setSortKey(key);
38
+ setSortDirection(nextDirection);
39
+ onSort?.(key, nextDirection);
40
+ },
41
+ [sortKey, sortDirection, onSort],
42
+ );
43
+
44
+ const wrapperClasses = ["strand-table-wrapper", className]
45
+ .filter(Boolean)
46
+ .join(" ");
47
+
48
+ return (
49
+ <div ref={ref} className={wrapperClasses} {...rest}>
50
+ <table className="strand-table">
51
+ <thead className="strand-table__head">
52
+ <tr>
53
+ {columns.map((col) => (
54
+ <th
55
+ key={col.key}
56
+ className="strand-table__th"
57
+ style={col.width ? { width: col.width } : undefined}
58
+ >
59
+ {col.sortable ? (
60
+ <button
61
+ type="button"
62
+ className="strand-table__sort-btn"
63
+ onClick={() => handleSort(col.key)}
64
+ aria-label={`Sort by ${col.header}`}
65
+ >
66
+ {col.header}
67
+ <span
68
+ className="strand-table__sort-indicator"
69
+ aria-hidden="true"
70
+ >
71
+ {sortKey === col.key
72
+ ? sortDirection === "asc"
73
+ ? "\u2191"
74
+ : "\u2193"
75
+ : "\u2195"}
76
+ </span>
77
+ </button>
78
+ ) : (
79
+ col.header
80
+ )}
81
+ </th>
82
+ ))}
83
+ </tr>
84
+ </thead>
85
+ <tbody className="strand-table__body">
86
+ {data.map((row, rowIndex) => (
87
+ <tr key={rowIndex} className="strand-table__row">
88
+ {columns.map((col) => (
89
+ <td key={col.key} className="strand-table__td">
90
+ {row[col.key]}
91
+ </td>
92
+ ))}
93
+ </tr>
94
+ ))}
95
+ </tbody>
96
+ </table>
97
+ </div>
98
+ );
99
+ },
100
+ );
101
+
102
+ Table.displayName = "Table";
@@ -0,0 +1,2 @@
1
+ export { Table } from "./Table.js";
2
+ export type { TableProps, TableColumn } from "./Table.js";
@@ -0,0 +1,51 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Tab list ── */
4
+ .strand-tabs [role="tablist"] {
5
+ display: flex;
6
+ gap: var(--strand-space-1);
7
+ border-bottom: 1px solid var(--strand-gray-400);
8
+ }
9
+
10
+ /* ── Tab button ── */
11
+ .strand-tabs__tab {
12
+ position: relative;
13
+ padding: var(--strand-space-2) var(--strand-space-4);
14
+ border: none;
15
+ border-bottom: 2px solid transparent;
16
+ background: none;
17
+ font-family: var(--strand-font-sans);
18
+ font-size: var(--strand-text-sm);
19
+ font-weight: var(--strand-weight-medium);
20
+ color: var(--strand-gray-500);
21
+ cursor: pointer;
22
+ transition:
23
+ color var(--strand-duration-fast) var(--strand-ease-out-quart),
24
+ border-color var(--strand-duration-fast) var(--strand-ease-out-expo);
25
+ }
26
+
27
+ .strand-tabs__tab:hover {
28
+ color: var(--strand-gray-600);
29
+ }
30
+
31
+ .strand-tabs__tab:focus-visible {
32
+ outline: 2px solid var(--strand-blue-primary);
33
+ outline-offset: 2px;
34
+ }
35
+
36
+ .strand-tabs__tab--active {
37
+ color: var(--strand-blue-primary);
38
+ border-bottom-color: var(--strand-blue-primary);
39
+ }
40
+
41
+ /* ── Panel ── */
42
+ .strand-tabs [role="tabpanel"] {
43
+ padding: var(--strand-space-4) 0;
44
+ }
45
+
46
+ /* ── Reduced motion ── */
47
+ @media (prefers-reduced-motion: reduce) {
48
+ .strand-tabs__tab {
49
+ transition: none;
50
+ }
51
+ }