@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,30 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Layout ── */
4
+ .strand-data-readout {
5
+ display: flex;
6
+ flex-direction: column;
7
+ gap: var(--strand-space-1);
8
+ }
9
+
10
+ /* ── Label (overline) ── */
11
+ .strand-data-readout__label {
12
+ font-family: var(--strand-font-mono);
13
+ font-size: var(--strand-text-xs);
14
+ font-weight: var(--strand-weight-medium);
15
+ letter-spacing: var(--strand-tracking-ultra);
16
+ text-transform: uppercase;
17
+ color: var(--strand-gray-500);
18
+ line-height: var(--strand-leading-normal);
19
+ }
20
+
21
+ /* ── Value (instrument readout) ── */
22
+ .strand-data-readout__value {
23
+ font-family: var(--strand-font-mono);
24
+ font-size: var(--strand-text-3xl);
25
+ font-weight: var(--strand-weight-light);
26
+ letter-spacing: var(--strand-tracking-tighter);
27
+ color: var(--strand-blue-midnight);
28
+ line-height: var(--strand-leading-tight);
29
+ font-variant-numeric: tabular-nums;
30
+ }
@@ -0,0 +1,105 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { render } from "@testing-library/preact";
3
+ import { DataReadout } from "./DataReadout.js";
4
+
5
+ describe("DataReadout", () => {
6
+ // ── Rendering ──
7
+
8
+ it("renders label text", () => {
9
+ const { getByText } = render(
10
+ <DataReadout label="Response Time" value="42ms" />,
11
+ );
12
+ expect(getByText("Response Time")).toBeTruthy();
13
+ });
14
+
15
+ it("renders string value", () => {
16
+ const { getByText } = render(
17
+ <DataReadout label="Status" value="Online" />,
18
+ );
19
+ expect(getByText("Online")).toBeTruthy();
20
+ });
21
+
22
+ it("renders numeric value", () => {
23
+ const { getByText } = render(
24
+ <DataReadout label="Count" value={1234} />,
25
+ );
26
+ expect(getByText("1234")).toBeTruthy();
27
+ });
28
+
29
+ // ── Typography classes ──
30
+
31
+ it("applies monospace font class to label", () => {
32
+ const { container } = render(
33
+ <DataReadout label="Metric" value="100" />,
34
+ );
35
+ const label = container.querySelector(".strand-data-readout__label");
36
+ expect(label).toBeTruthy();
37
+ });
38
+
39
+ it("applies monospace font class to value", () => {
40
+ const { container } = render(
41
+ <DataReadout label="Metric" value="100" />,
42
+ );
43
+ const value = container.querySelector(".strand-data-readout__value");
44
+ expect(value).toBeTruthy();
45
+ });
46
+
47
+ it("label has uppercase text transform via CSS class", () => {
48
+ const { container } = render(
49
+ <DataReadout label="Metric" value="100" />,
50
+ );
51
+ const label = container.querySelector(".strand-data-readout__label");
52
+ expect(label).toBeTruthy();
53
+ // text-transform: uppercase is applied via .strand-data-readout__label CSS
54
+ });
55
+
56
+ it("value uses light weight via CSS class", () => {
57
+ const { container } = render(
58
+ <DataReadout label="Metric" value="100" />,
59
+ );
60
+ const value = container.querySelector(".strand-data-readout__value");
61
+ expect(value).toBeTruthy();
62
+ // font-weight: var(--strand-weight-light) applied via .strand-data-readout__value CSS
63
+ });
64
+
65
+ it("value has tabular-nums via CSS class", () => {
66
+ const { container } = render(
67
+ <DataReadout label="Metric" value="100" />,
68
+ );
69
+ const value = container.querySelector(".strand-data-readout__value");
70
+ expect(value).toBeTruthy();
71
+ // font-variant-numeric: tabular-nums applied via .strand-data-readout__value CSS
72
+ });
73
+
74
+ // ── Layout ──
75
+
76
+ it("renders as flex column layout", () => {
77
+ const { container } = render(
78
+ <DataReadout label="Metric" value="100" />,
79
+ );
80
+ const readout = container.querySelector(".strand-data-readout");
81
+ expect(readout).toBeTruthy();
82
+ // display: flex; flex-direction: column applied via .strand-data-readout CSS
83
+ });
84
+
85
+ // ── Custom className ──
86
+
87
+ it("merges custom className with component classes", () => {
88
+ const { container } = render(
89
+ <DataReadout label="Metric" value="100" className="custom" />,
90
+ );
91
+ const readout = container.querySelector(".strand-data-readout");
92
+ expect(readout?.className).toContain("strand-data-readout");
93
+ expect(readout?.className).toContain("custom");
94
+ });
95
+
96
+ // ── Forwarded props ──
97
+
98
+ it("forwards additional props", () => {
99
+ const { container } = render(
100
+ <DataReadout label="Metric" value="100" id="readout-1" />,
101
+ );
102
+ const readout = container.querySelector(".strand-data-readout");
103
+ expect(readout?.getAttribute("id")).toBe("readout-1");
104
+ });
105
+ });
@@ -0,0 +1,29 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+
6
+ export interface DataReadoutProps
7
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "label"> {
8
+ /** Overline label text */
9
+ label: string;
10
+ /** The large displayed value */
11
+ value: string | number;
12
+ }
13
+
14
+ export const DataReadout = forwardRef<HTMLDivElement, DataReadoutProps>(
15
+ ({ label, value, className = "", ...rest }, ref) => {
16
+ const classes = ["strand-data-readout", className]
17
+ .filter(Boolean)
18
+ .join(" ");
19
+
20
+ return (
21
+ <div ref={ref} className={classes} {...rest}>
22
+ <span className="strand-data-readout__label">{label}</span>
23
+ <span className="strand-data-readout__value">{value}</span>
24
+ </div>
25
+ );
26
+ },
27
+ );
28
+
29
+ DataReadout.displayName = "DataReadout";
@@ -0,0 +1,2 @@
1
+ export { DataReadout } from "./DataReadout.js";
2
+ export type { DataReadoutProps } from "./DataReadout.js";
@@ -0,0 +1,81 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Backdrop ── */
4
+ .strand-dialog__backdrop {
5
+ position: fixed;
6
+ inset: 0;
7
+ z-index: 1000;
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ background: var(--strand-backdrop);
12
+ }
13
+
14
+ /* ── Panel ── */
15
+ .strand-dialog__panel {
16
+ position: relative;
17
+ width: 100%;
18
+ max-width: 560px;
19
+ margin: var(--strand-space-4);
20
+ padding: var(--strand-space-8);
21
+ background: var(--strand-surface-elevated);
22
+ border-radius: var(--strand-radius-xl);
23
+ box-shadow: var(--strand-elevation-4);
24
+ font-family: var(--strand-font-sans);
25
+ outline: none;
26
+ }
27
+
28
+ /* ── Header ── */
29
+ .strand-dialog__header {
30
+ margin-bottom: var(--strand-space-4);
31
+ padding-right: var(--strand-space-8);
32
+ }
33
+
34
+ /* ── Title ── */
35
+ .strand-dialog__title {
36
+ margin: 0;
37
+ font-size: var(--strand-text-lg);
38
+ font-weight: var(--strand-weight-semibold);
39
+ color: var(--strand-blue-midnight);
40
+ line-height: var(--strand-leading-snug);
41
+ }
42
+
43
+ /* ── Close button ── */
44
+ .strand-dialog__close {
45
+ position: absolute;
46
+ top: var(--strand-space-6);
47
+ right: var(--strand-space-6);
48
+ display: inline-flex;
49
+ align-items: center;
50
+ justify-content: center;
51
+ width: 32px;
52
+ height: 32px;
53
+ padding: 0;
54
+ border: none;
55
+ border-radius: var(--strand-radius-md);
56
+ background: transparent;
57
+ color: var(--strand-gray-500);
58
+ font-size: var(--strand-text-lg);
59
+ cursor: pointer;
60
+ transition: background var(--strand-duration-fast) var(--strand-ease-out-quart),
61
+ color var(--strand-duration-fast) var(--strand-ease-out-quart);
62
+ }
63
+
64
+ .strand-dialog__close:hover {
65
+ background: var(--strand-gray-200);
66
+ color: var(--strand-gray-900);
67
+ }
68
+
69
+ /* ── Body ── */
70
+ .strand-dialog__body {
71
+ padding-top: var(--strand-space-6);
72
+ color: var(--strand-gray-600);
73
+ font-size: var(--strand-text-sm);
74
+ }
75
+
76
+ /* ── Reduced motion ── */
77
+ @media (prefers-reduced-motion: reduce) {
78
+ .strand-dialog__close {
79
+ transition: none;
80
+ }
81
+ }
@@ -0,0 +1,203 @@
1
+ import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
2
+ import { render, fireEvent } from "@testing-library/preact";
3
+ import { Dialog } from "./Dialog.js";
4
+
5
+ describe("Dialog", () => {
6
+ const defaultProps = {
7
+ open: true,
8
+ onClose: vi.fn(),
9
+ };
10
+
11
+ beforeEach(() => {
12
+ vi.clearAllMocks();
13
+ });
14
+
15
+ afterEach(() => {
16
+ // Ensure body overflow is restored
17
+ document.body.style.overflow = "";
18
+ });
19
+
20
+ // ── Rendering ──
21
+
22
+ it("renders nothing when closed", () => {
23
+ const { container } = render(
24
+ <Dialog open={false} onClose={defaultProps.onClose}>
25
+ Content
26
+ </Dialog>,
27
+ );
28
+ expect(container.innerHTML).toBe("");
29
+ });
30
+
31
+ it("renders dialog when open", () => {
32
+ const { getByRole } = render(
33
+ <Dialog {...defaultProps}>Content</Dialog>,
34
+ );
35
+ expect(getByRole("dialog")).toBeTruthy();
36
+ });
37
+
38
+ it("renders children inside the dialog", () => {
39
+ const { getByRole } = render(
40
+ <Dialog {...defaultProps}>
41
+ <p>Dialog content</p>
42
+ </Dialog>,
43
+ );
44
+ expect(getByRole("dialog")).toHaveTextContent("Dialog content");
45
+ });
46
+
47
+ // ── ARIA ──
48
+
49
+ it("has role dialog", () => {
50
+ const { getByRole } = render(
51
+ <Dialog {...defaultProps}>Content</Dialog>,
52
+ );
53
+ expect(getByRole("dialog")).toBeTruthy();
54
+ });
55
+
56
+ it("has aria-modal true", () => {
57
+ const { getByRole } = render(
58
+ <Dialog {...defaultProps}>Content</Dialog>,
59
+ );
60
+ expect(getByRole("dialog")).toHaveAttribute("aria-modal", "true");
61
+ });
62
+
63
+ it("renders title with aria-labelledby linkage", () => {
64
+ const { getByRole, getByText } = render(
65
+ <Dialog {...defaultProps} title="My Dialog">
66
+ Content
67
+ </Dialog>,
68
+ );
69
+ const dialog = getByRole("dialog");
70
+ const titleEl = getByText("My Dialog");
71
+ const titleId = titleEl.getAttribute("id");
72
+ expect(dialog).toHaveAttribute("aria-labelledby", titleId);
73
+ });
74
+
75
+ it("does not set aria-labelledby when no title", () => {
76
+ const { getByRole } = render(
77
+ <Dialog {...defaultProps}>Content</Dialog>,
78
+ );
79
+ expect(getByRole("dialog").hasAttribute("aria-labelledby")).toBe(false);
80
+ });
81
+
82
+ // ── Title ──
83
+
84
+ it("renders the title text", () => {
85
+ const { getByText } = render(
86
+ <Dialog {...defaultProps} title="Confirm Action">
87
+ Content
88
+ </Dialog>,
89
+ );
90
+ expect(getByText("Confirm Action")).toBeTruthy();
91
+ });
92
+
93
+ // ── Close button ──
94
+
95
+ it("close button calls onClose", () => {
96
+ const onClose = vi.fn();
97
+ const { getByLabelText } = render(
98
+ <Dialog open={true} onClose={onClose}>
99
+ Content
100
+ </Dialog>,
101
+ );
102
+ fireEvent.click(getByLabelText("Close"));
103
+ expect(onClose).toHaveBeenCalledTimes(1);
104
+ });
105
+
106
+ // ── Escape key ──
107
+
108
+ it("Escape key calls onClose", () => {
109
+ const onClose = vi.fn();
110
+ const { getByRole } = render(
111
+ <Dialog open={true} onClose={onClose}>
112
+ Content
113
+ </Dialog>,
114
+ );
115
+ fireEvent.keyDown(getByRole("dialog").parentElement!, {
116
+ key: "Escape",
117
+ });
118
+ expect(onClose).toHaveBeenCalledTimes(1);
119
+ });
120
+
121
+ it("Escape key does not call onClose when closeOnEscape is false", () => {
122
+ const onClose = vi.fn();
123
+ const { getByRole } = render(
124
+ <Dialog open={true} onClose={onClose} closeOnEscape={false}>
125
+ Content
126
+ </Dialog>,
127
+ );
128
+ fireEvent.keyDown(getByRole("dialog").parentElement!, {
129
+ key: "Escape",
130
+ });
131
+ expect(onClose).not.toHaveBeenCalled();
132
+ });
133
+
134
+ // ── Outside click ──
135
+
136
+ it("clicking backdrop calls onClose", () => {
137
+ const onClose = vi.fn();
138
+ const { container } = render(
139
+ <Dialog open={true} onClose={onClose}>
140
+ Content
141
+ </Dialog>,
142
+ );
143
+ const backdrop = container.querySelector(".strand-dialog__backdrop")!;
144
+ fireEvent.click(backdrop);
145
+ expect(onClose).toHaveBeenCalledTimes(1);
146
+ });
147
+
148
+ it("clicking inside dialog does not call onClose", () => {
149
+ const onClose = vi.fn();
150
+ const { getByRole } = render(
151
+ <Dialog open={true} onClose={onClose}>
152
+ Content
153
+ </Dialog>,
154
+ );
155
+ fireEvent.click(getByRole("dialog"));
156
+ expect(onClose).not.toHaveBeenCalled();
157
+ });
158
+
159
+ it("backdrop click disabled when closeOnOutsideClick is false", () => {
160
+ const onClose = vi.fn();
161
+ const { container } = render(
162
+ <Dialog open={true} onClose={onClose} closeOnOutsideClick={false}>
163
+ Content
164
+ </Dialog>,
165
+ );
166
+ const backdrop = container.querySelector(".strand-dialog__backdrop")!;
167
+ fireEvent.click(backdrop);
168
+ expect(onClose).not.toHaveBeenCalled();
169
+ });
170
+
171
+ // ── Custom className ──
172
+
173
+ it("merges custom className", () => {
174
+ const { getByRole } = render(
175
+ <Dialog {...defaultProps} className="custom-dialog">
176
+ Content
177
+ </Dialog>,
178
+ );
179
+ const dialog = getByRole("dialog");
180
+ expect(dialog.className).toContain("strand-dialog__panel");
181
+ expect(dialog.className).toContain("custom-dialog");
182
+ });
183
+
184
+ // ── Scroll lock ──
185
+
186
+ it("sets body overflow hidden when open", () => {
187
+ render(<Dialog {...defaultProps}>Content</Dialog>);
188
+ expect(document.body.style.overflow).toBe("hidden");
189
+ });
190
+
191
+ it("restores body overflow when closed", () => {
192
+ const { rerender } = render(
193
+ <Dialog {...defaultProps}>Content</Dialog>,
194
+ );
195
+ expect(document.body.style.overflow).toBe("hidden");
196
+ rerender(
197
+ <Dialog open={false} onClose={defaultProps.onClose}>
198
+ Content
199
+ </Dialog>,
200
+ );
201
+ expect(document.body.style.overflow).toBe("");
202
+ });
203
+ });
@@ -0,0 +1,179 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ import type { ComponentChildren, JSX } from "preact";
4
+ import { forwardRef } from "preact/compat";
5
+ import { useEffect, useRef, useCallback } from "preact/hooks";
6
+
7
+ export interface DialogProps
8
+ extends Omit<JSX.HTMLAttributes<HTMLDivElement>, "title" | "open"> {
9
+ /** Whether the dialog is open */
10
+ open: boolean;
11
+ /** Called when the dialog should close */
12
+ onClose: () => void;
13
+ /** Optional title rendered in the dialog header */
14
+ title?: string;
15
+ /** Close when clicking the backdrop */
16
+ closeOnOutsideClick?: boolean;
17
+ /** Close when pressing Escape */
18
+ closeOnEscape?: boolean;
19
+ /** Dialog content */
20
+ children?: ComponentChildren;
21
+ }
22
+
23
+ const FOCUSABLE_SELECTOR =
24
+ 'a[href], button:not(:disabled), textarea:not(:disabled), input:not(:disabled), select:not(:disabled), [tabindex]:not([tabindex="-1"])';
25
+
26
+ let dialogIdCounter = 0;
27
+
28
+ export const Dialog = forwardRef<HTMLDivElement, DialogProps>(
29
+ (
30
+ {
31
+ open,
32
+ onClose,
33
+ title,
34
+ closeOnOutsideClick = true,
35
+ closeOnEscape = true,
36
+ className = "",
37
+ children,
38
+ ...rest
39
+ },
40
+ ref,
41
+ ) => {
42
+ const panelRef = useRef<HTMLDivElement>(null);
43
+ const previousFocusRef = useRef<Element | null>(null);
44
+ const idRef = useRef(`strand-dialog-title-${++dialogIdCounter}`);
45
+ const titleId = idRef.current;
46
+
47
+ // Focus trap and focus restoration
48
+ useEffect(() => {
49
+ if (!open) return;
50
+
51
+ previousFocusRef.current = document.activeElement;
52
+
53
+ // Small delay to allow the DOM to render before querying focusable elements
54
+ const raf = requestAnimationFrame(() => {
55
+ const panel = panelRef.current;
56
+ if (!panel) return;
57
+ const focusable = panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR);
58
+ if (focusable.length > 0) {
59
+ focusable[0].focus();
60
+ } else {
61
+ panel.focus();
62
+ }
63
+ });
64
+
65
+ return () => {
66
+ cancelAnimationFrame(raf);
67
+ const prev = previousFocusRef.current;
68
+ if (prev && prev instanceof HTMLElement) {
69
+ prev.focus();
70
+ }
71
+ };
72
+ }, [open]);
73
+
74
+ // Scroll lock
75
+ useEffect(() => {
76
+ if (!open) return;
77
+
78
+ const original = document.body.style.overflow;
79
+ document.body.style.overflow = "hidden";
80
+
81
+ return () => {
82
+ document.body.style.overflow = original;
83
+ };
84
+ }, [open]);
85
+
86
+ // Keyboard handler
87
+ const handleKeyDown = useCallback(
88
+ (e: KeyboardEvent) => {
89
+ if (e.key === "Escape" && closeOnEscape) {
90
+ e.stopPropagation();
91
+ onClose();
92
+ return;
93
+ }
94
+
95
+ if (e.key === "Tab") {
96
+ const panel = panelRef.current;
97
+ if (!panel) return;
98
+
99
+ const focusable = Array.from(
100
+ panel.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR),
101
+ );
102
+ if (focusable.length === 0) return;
103
+
104
+ const first = focusable[0];
105
+ const last = focusable[focusable.length - 1];
106
+
107
+ if (e.shiftKey) {
108
+ if (document.activeElement === first) {
109
+ e.preventDefault();
110
+ last.focus();
111
+ }
112
+ } else {
113
+ if (document.activeElement === last) {
114
+ e.preventDefault();
115
+ first.focus();
116
+ }
117
+ }
118
+ }
119
+ },
120
+ [closeOnEscape, onClose],
121
+ );
122
+
123
+ const handleBackdropClick = useCallback(
124
+ (e: MouseEvent) => {
125
+ if (closeOnOutsideClick && e.target === e.currentTarget) {
126
+ onClose();
127
+ }
128
+ },
129
+ [closeOnOutsideClick, onClose],
130
+ );
131
+
132
+ if (!open) return null;
133
+
134
+ const classes = ["strand-dialog__panel", className]
135
+ .filter(Boolean)
136
+ .join(" ");
137
+
138
+ return (
139
+ <div
140
+ className="strand-dialog__backdrop"
141
+ onClick={handleBackdropClick}
142
+ onKeyDown={handleKeyDown}
143
+ >
144
+ <div
145
+ ref={(el) => {
146
+ (panelRef as { current: HTMLDivElement | null }).current = el;
147
+ if (typeof ref === "function") ref(el);
148
+ else if (ref) (ref as { current: HTMLDivElement | null }).current = el;
149
+ }}
150
+ className={classes}
151
+ role="dialog"
152
+ aria-modal="true"
153
+ aria-labelledby={title ? titleId : undefined}
154
+ tabIndex={-1}
155
+ {...rest}
156
+ >
157
+ {title && (
158
+ <div className="strand-dialog__header">
159
+ <h2 id={titleId} className="strand-dialog__title">
160
+ {title}
161
+ </h2>
162
+ </div>
163
+ )}
164
+ <button
165
+ type="button"
166
+ className="strand-dialog__close"
167
+ aria-label="Close"
168
+ onClick={onClose}
169
+ >
170
+ &#215;
171
+ </button>
172
+ <div className="strand-dialog__body">{children}</div>
173
+ </div>
174
+ </div>
175
+ );
176
+ },
177
+ );
178
+
179
+ Dialog.displayName = "Dialog";
@@ -0,0 +1,2 @@
1
+ export { Dialog } from "./Dialog.js";
2
+ export type { DialogProps } from "./Dialog.js";
@@ -0,0 +1,44 @@
1
+ /*! Strand UI | MIT License | dillingerstaffing.com */
2
+
3
+ /* ── Base ── */
4
+ .strand-divider {
5
+ border: 0;
6
+ margin: 0;
7
+ padding: 0;
8
+ }
9
+
10
+ /* ── Horizontal (hr) ── */
11
+ .strand-divider--horizontal {
12
+ width: 100%;
13
+ border-top: 1px solid var(--strand-gray-200);
14
+ }
15
+
16
+ /* ── Vertical ── */
17
+ .strand-divider--vertical {
18
+ align-self: stretch;
19
+ border-left: 1px solid var(--strand-gray-200);
20
+ }
21
+
22
+ /* ── Labeled ── */
23
+ .strand-divider--labeled {
24
+ display: flex;
25
+ align-items: center;
26
+ gap: var(--strand-space-3);
27
+ border-top: 0;
28
+ }
29
+
30
+ .strand-divider__line {
31
+ flex: 1;
32
+ height: 0;
33
+ border-top: 1px solid var(--strand-gray-200);
34
+ }
35
+
36
+ .strand-divider__label {
37
+ font-family: var(--strand-font-mono);
38
+ font-size: var(--strand-text-xs);
39
+ font-weight: var(--strand-weight-medium);
40
+ color: var(--strand-gray-400);
41
+ letter-spacing: var(--strand-tracking-widest);
42
+ text-transform: uppercase;
43
+ white-space: nowrap;
44
+ }