@lab206/ui 0.1.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 (327) hide show
  1. package/LICENSE +114 -0
  2. package/README.md +6 -0
  3. package/dist/components/Accordion.d.ts +25 -0
  4. package/dist/components/Accordion.d.ts.map +1 -0
  5. package/dist/components/Accordion.js +123 -0
  6. package/dist/components/Accordion.js.map +1 -0
  7. package/dist/components/Alert.d.ts +17 -0
  8. package/dist/components/Alert.d.ts.map +1 -0
  9. package/dist/components/Alert.js +59 -0
  10. package/dist/components/Alert.js.map +1 -0
  11. package/dist/components/AspectRatio.d.ts +13 -0
  12. package/dist/components/AspectRatio.d.ts.map +1 -0
  13. package/dist/components/AspectRatio.js +38 -0
  14. package/dist/components/AspectRatio.js.map +1 -0
  15. package/dist/components/Avatar.d.ts +17 -0
  16. package/dist/components/Avatar.d.ts.map +1 -0
  17. package/dist/components/Avatar.js +57 -0
  18. package/dist/components/Avatar.js.map +1 -0
  19. package/dist/components/Badge.d.ts +12 -0
  20. package/dist/components/Badge.d.ts.map +1 -0
  21. package/dist/components/Badge.js +62 -0
  22. package/dist/components/Badge.js.map +1 -0
  23. package/dist/components/Breadcrumb.d.ts +16 -0
  24. package/dist/components/Breadcrumb.d.ts.map +1 -0
  25. package/dist/components/Breadcrumb.js +76 -0
  26. package/dist/components/Breadcrumb.js.map +1 -0
  27. package/dist/components/Button.d.ts +28 -0
  28. package/dist/components/Button.d.ts.map +1 -0
  29. package/dist/components/Button.js +110 -0
  30. package/dist/components/Button.js.map +1 -0
  31. package/dist/components/Card.d.ts +18 -0
  32. package/dist/components/Card.d.ts.map +1 -0
  33. package/dist/components/Card.js +57 -0
  34. package/dist/components/Card.js.map +1 -0
  35. package/dist/components/Checkbox.d.ts +23 -0
  36. package/dist/components/Checkbox.d.ts.map +1 -0
  37. package/dist/components/Checkbox.js +107 -0
  38. package/dist/components/Checkbox.js.map +1 -0
  39. package/dist/components/Chip.d.ts +18 -0
  40. package/dist/components/Chip.d.ts.map +1 -0
  41. package/dist/components/Chip.js +103 -0
  42. package/dist/components/Chip.js.map +1 -0
  43. package/dist/components/Code.d.ts +12 -0
  44. package/dist/components/Code.d.ts.map +1 -0
  45. package/dist/components/Code.js +57 -0
  46. package/dist/components/Code.js.map +1 -0
  47. package/dist/components/Collapse.d.ts +15 -0
  48. package/dist/components/Collapse.d.ts.map +1 -0
  49. package/dist/components/Collapse.js +49 -0
  50. package/dist/components/Collapse.js.map +1 -0
  51. package/dist/components/Combobox.d.ts +47 -0
  52. package/dist/components/Combobox.d.ts.map +1 -0
  53. package/dist/components/Combobox.js +325 -0
  54. package/dist/components/Combobox.js.map +1 -0
  55. package/dist/components/Command.d.ts +42 -0
  56. package/dist/components/Command.d.ts.map +1 -0
  57. package/dist/components/Command.js +263 -0
  58. package/dist/components/Command.js.map +1 -0
  59. package/dist/components/Container.d.ts +11 -0
  60. package/dist/components/Container.d.ts.map +1 -0
  61. package/dist/components/Container.js +38 -0
  62. package/dist/components/Container.js.map +1 -0
  63. package/dist/components/Dialog.d.ts +26 -0
  64. package/dist/components/Dialog.d.ts.map +1 -0
  65. package/dist/components/Dialog.js +159 -0
  66. package/dist/components/Dialog.js.map +1 -0
  67. package/dist/components/Divider.d.ts +10 -0
  68. package/dist/components/Divider.d.ts.map +1 -0
  69. package/dist/components/Divider.js +52 -0
  70. package/dist/components/Divider.js.map +1 -0
  71. package/dist/components/Drawer.d.ts +19 -0
  72. package/dist/components/Drawer.d.ts.map +1 -0
  73. package/dist/components/Drawer.js +131 -0
  74. package/dist/components/Drawer.js.map +1 -0
  75. package/dist/components/Empty.d.ts +17 -0
  76. package/dist/components/Empty.d.ts.map +1 -0
  77. package/dist/components/Empty.js +51 -0
  78. package/dist/components/Empty.js.map +1 -0
  79. package/dist/components/Field.d.ts +27 -0
  80. package/dist/components/Field.d.ts.map +1 -0
  81. package/dist/components/Field.js +153 -0
  82. package/dist/components/Field.js.map +1 -0
  83. package/dist/components/Grid.d.ts +13 -0
  84. package/dist/components/Grid.d.ts.map +1 -0
  85. package/dist/components/Grid.js +44 -0
  86. package/dist/components/Grid.js.map +1 -0
  87. package/dist/components/Input.d.ts +55 -0
  88. package/dist/components/Input.d.ts.map +1 -0
  89. package/dist/components/Input.js +113 -0
  90. package/dist/components/Input.js.map +1 -0
  91. package/dist/components/Kbd.d.ts +10 -0
  92. package/dist/components/Kbd.d.ts.map +1 -0
  93. package/dist/components/Kbd.js +41 -0
  94. package/dist/components/Kbd.js.map +1 -0
  95. package/dist/components/Label.d.ts +13 -0
  96. package/dist/components/Label.d.ts.map +1 -0
  97. package/dist/components/Label.js +34 -0
  98. package/dist/components/Label.js.map +1 -0
  99. package/dist/components/Link.d.ts +18 -0
  100. package/dist/components/Link.d.ts.map +1 -0
  101. package/dist/components/Link.js +41 -0
  102. package/dist/components/Link.js.map +1 -0
  103. package/dist/components/List.d.ts +22 -0
  104. package/dist/components/List.d.ts.map +1 -0
  105. package/dist/components/List.js +157 -0
  106. package/dist/components/List.js.map +1 -0
  107. package/dist/components/Menu.d.ts +28 -0
  108. package/dist/components/Menu.d.ts.map +1 -0
  109. package/dist/components/Menu.js +126 -0
  110. package/dist/components/Menu.js.map +1 -0
  111. package/dist/components/NumberInput.d.ts +27 -0
  112. package/dist/components/NumberInput.d.ts.map +1 -0
  113. package/dist/components/NumberInput.js +92 -0
  114. package/dist/components/NumberInput.js.map +1 -0
  115. package/dist/components/Pagination.d.ts +13 -0
  116. package/dist/components/Pagination.d.ts.map +1 -0
  117. package/dist/components/Pagination.js +104 -0
  118. package/dist/components/Pagination.js.map +1 -0
  119. package/dist/components/Popover.d.ts +26 -0
  120. package/dist/components/Popover.d.ts.map +1 -0
  121. package/dist/components/Popover.js +165 -0
  122. package/dist/components/Popover.js.map +1 -0
  123. package/dist/components/Progress.d.ts +15 -0
  124. package/dist/components/Progress.d.ts.map +1 -0
  125. package/dist/components/Progress.js +63 -0
  126. package/dist/components/Progress.js.map +1 -0
  127. package/dist/components/Radio.d.ts +25 -0
  128. package/dist/components/Radio.d.ts.map +1 -0
  129. package/dist/components/Radio.js +95 -0
  130. package/dist/components/Radio.js.map +1 -0
  131. package/dist/components/ScrollArea.d.ts +13 -0
  132. package/dist/components/ScrollArea.d.ts.map +1 -0
  133. package/dist/components/ScrollArea.js +33 -0
  134. package/dist/components/ScrollArea.js.map +1 -0
  135. package/dist/components/Select.d.ts +34 -0
  136. package/dist/components/Select.d.ts.map +1 -0
  137. package/dist/components/Select.js +108 -0
  138. package/dist/components/Select.js.map +1 -0
  139. package/dist/components/Skeleton.d.ts +18 -0
  140. package/dist/components/Skeleton.d.ts.map +1 -0
  141. package/dist/components/Skeleton.js +86 -0
  142. package/dist/components/Skeleton.js.map +1 -0
  143. package/dist/components/Slider.d.ts +26 -0
  144. package/dist/components/Slider.d.ts.map +1 -0
  145. package/dist/components/Slider.js +113 -0
  146. package/dist/components/Slider.js.map +1 -0
  147. package/dist/components/Spinner.d.ts +12 -0
  148. package/dist/components/Spinner.d.ts.map +1 -0
  149. package/dist/components/Spinner.js +44 -0
  150. package/dist/components/Spinner.js.map +1 -0
  151. package/dist/components/Stack.d.ts +21 -0
  152. package/dist/components/Stack.d.ts.map +1 -0
  153. package/dist/components/Stack.js +48 -0
  154. package/dist/components/Stack.js.map +1 -0
  155. package/dist/components/Stat.d.ts +23 -0
  156. package/dist/components/Stat.d.ts.map +1 -0
  157. package/dist/components/Stat.js +115 -0
  158. package/dist/components/Stat.js.map +1 -0
  159. package/dist/components/Steps.d.ts +20 -0
  160. package/dist/components/Steps.d.ts.map +1 -0
  161. package/dist/components/Steps.js +126 -0
  162. package/dist/components/Steps.js.map +1 -0
  163. package/dist/components/Switch.d.ts +23 -0
  164. package/dist/components/Switch.d.ts.map +1 -0
  165. package/dist/components/Switch.js +108 -0
  166. package/dist/components/Switch.js.map +1 -0
  167. package/dist/components/Table.d.ts +27 -0
  168. package/dist/components/Table.d.ts.map +1 -0
  169. package/dist/components/Table.js +112 -0
  170. package/dist/components/Table.js.map +1 -0
  171. package/dist/components/Tabs.d.ts +27 -0
  172. package/dist/components/Tabs.d.ts.map +1 -0
  173. package/dist/components/Tabs.js +171 -0
  174. package/dist/components/Tabs.js.map +1 -0
  175. package/dist/components/Text.d.ts +17 -0
  176. package/dist/components/Text.d.ts.map +1 -0
  177. package/dist/components/Text.js +48 -0
  178. package/dist/components/Text.js.map +1 -0
  179. package/dist/components/Textarea.d.ts +32 -0
  180. package/dist/components/Textarea.d.ts.map +1 -0
  181. package/dist/components/Textarea.js +91 -0
  182. package/dist/components/Textarea.js.map +1 -0
  183. package/dist/components/Timeline.d.ts +17 -0
  184. package/dist/components/Timeline.d.ts.map +1 -0
  185. package/dist/components/Timeline.js +95 -0
  186. package/dist/components/Timeline.js.map +1 -0
  187. package/dist/components/Toast.d.ts +36 -0
  188. package/dist/components/Toast.d.ts.map +1 -0
  189. package/dist/components/Toast.js +143 -0
  190. package/dist/components/Toast.js.map +1 -0
  191. package/dist/components/ToggleGroup.d.ts +25 -0
  192. package/dist/components/ToggleGroup.d.ts.map +1 -0
  193. package/dist/components/ToggleGroup.js +103 -0
  194. package/dist/components/ToggleGroup.js.map +1 -0
  195. package/dist/components/Tooltip.d.ts +19 -0
  196. package/dist/components/Tooltip.d.ts.map +1 -0
  197. package/dist/components/Tooltip.js +141 -0
  198. package/dist/components/Tooltip.js.map +1 -0
  199. package/dist/components/Transition.d.ts +22 -0
  200. package/dist/components/Transition.d.ts.map +1 -0
  201. package/dist/components/Transition.js +139 -0
  202. package/dist/components/Transition.js.map +1 -0
  203. package/dist/density.d.ts +21 -0
  204. package/dist/density.d.ts.map +1 -0
  205. package/dist/density.js +31 -0
  206. package/dist/density.js.map +1 -0
  207. package/dist/dev.d.ts +27 -0
  208. package/dist/dev.d.ts.map +1 -0
  209. package/dist/dev.js +77 -0
  210. package/dist/dev.js.map +1 -0
  211. package/dist/focusTrap.d.ts +6 -0
  212. package/dist/focusTrap.d.ts.map +1 -0
  213. package/dist/focusTrap.js +55 -0
  214. package/dist/focusTrap.js.map +1 -0
  215. package/dist/form.d.ts +125 -0
  216. package/dist/form.d.ts.map +1 -0
  217. package/dist/form.js +192 -0
  218. package/dist/form.js.map +1 -0
  219. package/dist/index.d.ts +134 -0
  220. package/dist/index.d.ts.map +1 -0
  221. package/dist/index.js +85 -0
  222. package/dist/index.js.map +1 -0
  223. package/dist/motion.d.ts +29 -0
  224. package/dist/motion.d.ts.map +1 -0
  225. package/dist/motion.js +43 -0
  226. package/dist/motion.js.map +1 -0
  227. package/dist/overlay.d.ts +63 -0
  228. package/dist/overlay.d.ts.map +1 -0
  229. package/dist/overlay.js +172 -0
  230. package/dist/overlay.js.map +1 -0
  231. package/dist/palette.d.ts +20 -0
  232. package/dist/palette.d.ts.map +1 -0
  233. package/dist/palette.js +52 -0
  234. package/dist/palette.js.map +1 -0
  235. package/dist/reactive.d.ts +25 -0
  236. package/dist/reactive.d.ts.map +1 -0
  237. package/dist/reactive.js +43 -0
  238. package/dist/reactive.js.map +1 -0
  239. package/dist/rovingFocus.d.ts +36 -0
  240. package/dist/rovingFocus.d.ts.map +1 -0
  241. package/dist/rovingFocus.js +127 -0
  242. package/dist/rovingFocus.js.map +1 -0
  243. package/dist/size-entries/form-kit.d.ts +12 -0
  244. package/dist/size-entries/form-kit.d.ts.map +1 -0
  245. package/dist/size-entries/form-kit.js +12 -0
  246. package/dist/size-entries/form-kit.js.map +1 -0
  247. package/dist/styles.d.ts +29 -0
  248. package/dist/styles.d.ts.map +1 -0
  249. package/dist/styles.js +53 -0
  250. package/dist/styles.js.map +1 -0
  251. package/dist/theme.d.ts +22 -0
  252. package/dist/theme.d.ts.map +1 -0
  253. package/dist/theme.js +32 -0
  254. package/dist/theme.js.map +1 -0
  255. package/dist/utils.d.ts +5 -0
  256. package/dist/utils.d.ts.map +1 -0
  257. package/dist/utils.js +24 -0
  258. package/dist/utils.js.map +1 -0
  259. package/package.json +71 -0
  260. package/src/components/Accordion.tsx +194 -0
  261. package/src/components/Alert.tsx +88 -0
  262. package/src/components/AspectRatio.tsx +64 -0
  263. package/src/components/Avatar.tsx +89 -0
  264. package/src/components/Badge.tsx +84 -0
  265. package/src/components/Breadcrumb.tsx +116 -0
  266. package/src/components/Button.tsx +148 -0
  267. package/src/components/Card.tsx +92 -0
  268. package/src/components/Checkbox.tsx +143 -0
  269. package/src/components/Chip.tsx +153 -0
  270. package/src/components/Code.tsx +71 -0
  271. package/src/components/Collapse.tsx +76 -0
  272. package/src/components/Combobox.tsx +438 -0
  273. package/src/components/Command.tsx +380 -0
  274. package/src/components/Container.tsx +58 -0
  275. package/src/components/Dialog.tsx +226 -0
  276. package/src/components/Divider.tsx +72 -0
  277. package/src/components/Drawer.tsx +191 -0
  278. package/src/components/Empty.tsx +86 -0
  279. package/src/components/Field.tsx +184 -0
  280. package/src/components/Grid.tsx +67 -0
  281. package/src/components/Input.tsx +169 -0
  282. package/src/components/Kbd.tsx +57 -0
  283. package/src/components/Label.tsx +54 -0
  284. package/src/components/Link.tsx +87 -0
  285. package/src/components/List.tsx +236 -0
  286. package/src/components/Menu.tsx +195 -0
  287. package/src/components/NumberInput.tsx +158 -0
  288. package/src/components/Pagination.tsx +153 -0
  289. package/src/components/Popover.tsx +219 -0
  290. package/src/components/Progress.tsx +108 -0
  291. package/src/components/Radio.tsx +161 -0
  292. package/src/components/ScrollArea.tsx +58 -0
  293. package/src/components/Select.tsx +128 -0
  294. package/src/components/Skeleton.tsx +117 -0
  295. package/src/components/Slider.tsx +175 -0
  296. package/src/components/Spinner.tsx +70 -0
  297. package/src/components/Stack.tsx +79 -0
  298. package/src/components/Stat.tsx +169 -0
  299. package/src/components/Steps.tsx +191 -0
  300. package/src/components/Switch.tsx +146 -0
  301. package/src/components/Table.tsx +168 -0
  302. package/src/components/Tabs.tsx +246 -0
  303. package/src/components/Text.tsx +69 -0
  304. package/src/components/Textarea.tsx +128 -0
  305. package/src/components/Timeline.tsx +150 -0
  306. package/src/components/Toast.tsx +221 -0
  307. package/src/components/ToggleGroup.tsx +161 -0
  308. package/src/components/Tooltip.tsx +187 -0
  309. package/src/components/Transition.tsx +155 -0
  310. package/src/density.ts +46 -0
  311. package/src/dev.ts +87 -0
  312. package/src/focusTrap.ts +61 -0
  313. package/src/form.ts +269 -0
  314. package/src/index.ts +243 -0
  315. package/src/motion.ts +62 -0
  316. package/src/overlay.ts +232 -0
  317. package/src/palette.ts +70 -0
  318. package/src/reactive.ts +54 -0
  319. package/src/rovingFocus.ts +171 -0
  320. package/src/size-entries/form-kit.ts +11 -0
  321. package/src/styles/base.css +70 -0
  322. package/src/styles/theme.css +14 -0
  323. package/src/styles/tokens.css +377 -0
  324. package/src/styles/utilities.css +216 -0
  325. package/src/styles.ts +52 -0
  326. package/src/theme.ts +48 -0
  327. package/src/utils.ts +25 -0
@@ -0,0 +1,155 @@
1
+ import { effect } from "@lab206/core";
2
+ import { component, type ComponentProps } from "@lab206/dom";
3
+ import { cx } from "../utils.js";
4
+ import { createStyleSheet } from "../styles.js";
5
+
6
+ export type TransitionProps = {
7
+ /** Show / hide the child */
8
+ show: boolean | (() => boolean);
9
+ /** CSS name prefix — generates name-enter, name-enter-active, name-exit, name-exit-active */
10
+ name?: string;
11
+ /** Fallback duration (ms) if no transitionend fires */
12
+ duration?: number;
13
+ class?: string | (() => string);
14
+ children?: unknown;
15
+ };
16
+
17
+ const ensure = createStyleSheet(
18
+ "transition",
19
+ `
20
+ .pu-transition { display: contents; }
21
+ .pu-fade-enter { opacity: 0; }
22
+ .pu-fade-enter-active {
23
+ opacity: 1;
24
+ transition: opacity var(--pu-duration) var(--pu-ease-out);
25
+ }
26
+ .pu-fade-exit { opacity: 1; }
27
+ .pu-fade-exit-active {
28
+ opacity: 0;
29
+ transition: opacity var(--pu-duration) var(--pu-ease);
30
+ }
31
+ .pu-collapse-enter {
32
+ overflow: hidden;
33
+ max-height: 0;
34
+ opacity: 0;
35
+ }
36
+ .pu-collapse-enter-active {
37
+ overflow: hidden;
38
+ max-height: 40rem;
39
+ opacity: 1;
40
+ transition:
41
+ max-height var(--pu-duration-slow) var(--pu-ease-out),
42
+ opacity var(--pu-duration) var(--pu-ease-out);
43
+ }
44
+ .pu-collapse-exit {
45
+ overflow: hidden;
46
+ max-height: 40rem;
47
+ opacity: 1;
48
+ }
49
+ .pu-collapse-exit-active {
50
+ overflow: hidden;
51
+ max-height: 0;
52
+ opacity: 0;
53
+ transition:
54
+ max-height var(--pu-duration-slow) var(--pu-ease),
55
+ opacity var(--pu-duration) var(--pu-ease);
56
+ }
57
+ @media (prefers-reduced-motion: reduce) {
58
+ .pu-fade-enter-active,
59
+ .pu-fade-exit-active,
60
+ .pu-collapse-enter-active,
61
+ .pu-collapse-exit-active { transition: none; }
62
+ }
63
+ `,
64
+ );
65
+
66
+ /**
67
+ * Enter/exit CSS transitions around children.
68
+ * Default name `"pu-fade"`; also built-in `"pu-collapse"`.
69
+ */
70
+ export const Transition = component((raw: TransitionProps) => {
71
+ ensure();
72
+ const props = raw as ComponentProps<TransitionProps>;
73
+ const name = () => props.name ?? "pu-fade";
74
+ const duration = () => props.duration ?? 220;
75
+
76
+ const host = document.createElement("div");
77
+ host.className = "pu-transition";
78
+ let childHost: HTMLElement | null = null;
79
+ let phase: "void" | "in" | "out" = "void";
80
+
81
+ const resolveChild = (): Node | null => {
82
+ const c = props.children;
83
+ if (c == null || c === false) return null;
84
+ if (c instanceof Node) return c;
85
+ if (typeof c === "function") {
86
+ const r = (c as () => unknown)();
87
+ if (r instanceof Node) return r;
88
+ if (r == null) return null;
89
+ return document.createTextNode(String(r));
90
+ }
91
+ return document.createTextNode(String(c));
92
+ };
93
+
94
+ const show = () => {
95
+ const s = props.show;
96
+ return typeof s === "function" ? !!(s as () => boolean)() : !!s;
97
+ };
98
+
99
+ effect(() => {
100
+ const on = show();
101
+ const n = name();
102
+ const doc = host.ownerDocument;
103
+ ensure(doc);
104
+ const win = doc.defaultView ?? window;
105
+
106
+ if (on) {
107
+ if (phase === "in") return;
108
+ phase = "in";
109
+ host.replaceChildren();
110
+ const wrap = doc.createElement("div");
111
+ wrap.className = cx(
112
+ typeof props.class === "function" ? props.class() : props.class,
113
+ );
114
+ const node = resolveChild();
115
+ if (node) wrap.appendChild(node);
116
+ childHost = wrap;
117
+ host.appendChild(wrap);
118
+ wrap.classList.add(`${n}-enter`);
119
+ // force reflow
120
+ void wrap.offsetWidth;
121
+ wrap.classList.add(`${n}-enter-active`);
122
+ const done = () => {
123
+ wrap.classList.remove(`${n}-enter`, `${n}-enter-active`);
124
+ };
125
+ const t = win.setTimeout(done, duration());
126
+ wrap.addEventListener("transitionend", done, { once: true });
127
+ return () => win.clearTimeout(t);
128
+ }
129
+
130
+ // exit
131
+ if (phase === "void" || !childHost) {
132
+ host.replaceChildren();
133
+ phase = "void";
134
+ return;
135
+ }
136
+ if (phase === "out") return;
137
+ phase = "out";
138
+ const wrap = childHost;
139
+ wrap.classList.remove(`${n}-enter`, `${n}-enter-active`);
140
+ wrap.classList.add(`${n}-exit`);
141
+ void wrap.offsetWidth;
142
+ wrap.classList.add(`${n}-exit-active`);
143
+ const finish = () => {
144
+ if (phase !== "out") return;
145
+ host.replaceChildren();
146
+ childHost = null;
147
+ phase = "void";
148
+ };
149
+ const t = win.setTimeout(finish, duration());
150
+ wrap.addEventListener("transitionend", finish, { once: true });
151
+ return () => win.clearTimeout(t);
152
+ });
153
+
154
+ return host;
155
+ });
package/src/density.ts ADDED
@@ -0,0 +1,46 @@
1
+ import { effect, signal, type Signal } from "@lab206/core";
2
+
3
+ export type Density = "comfortable" | "compact";
4
+
5
+ export interface DensityController {
6
+ density: Signal<Density>;
7
+ setDensity(next: Density): void;
8
+ toggle(): void;
9
+ /** Apply `data-pu-density` (default: document.documentElement) */
10
+ bind(target?: Element): () => void;
11
+ }
12
+
13
+ /**
14
+ * Control spacing / control height density via CSS tokens.
15
+ *
16
+ * @example
17
+ * ```ts
18
+ * const density = createDensity("comfortable");
19
+ * density.bind();
20
+ * density.setDensity("compact");
21
+ * ```
22
+ */
23
+ export function createDensity(initial: Density = "comfortable"): DensityController {
24
+ const density = signal<Density>(initial);
25
+
26
+ function setDensity(next: Density) {
27
+ density.set(next);
28
+ }
29
+
30
+ function toggle() {
31
+ density.update((d) => (d === "comfortable" ? "compact" : "comfortable"));
32
+ }
33
+
34
+ function bind(target?: Element) {
35
+ const el =
36
+ target ??
37
+ (typeof document !== "undefined" ? document.documentElement : null);
38
+ if (!el) return () => {};
39
+
40
+ return effect(() => {
41
+ el.setAttribute("data-pu-density", density());
42
+ });
43
+ }
44
+
45
+ return { density, setDensity, toggle, bind };
46
+ }
package/src/dev.ts ADDED
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Lightweight development warnings for common Powers mistakes.
3
+ * No-ops when `NODE_ENV === "production"` or when disabled.
4
+ *
5
+ * Call `installDevWarnings()` once from your app bootstrap (demos do this).
6
+ */
7
+
8
+ const g = globalThis as unknown as {
9
+ process?: { env?: { NODE_ENV?: string } };
10
+ __POWER_UI_DEV__?: boolean;
11
+ __POWER_UI_WARNED__?: Set<string>;
12
+ };
13
+
14
+ function isDev(): boolean {
15
+ if (g.__POWER_UI_DEV__ === false) return false;
16
+ if (g.__POWER_UI_DEV__ === true) return true;
17
+ const env = g.process?.env?.NODE_ENV;
18
+ if (env === "production") return false;
19
+ // Browser demos / Vite: treat as dev unless explicitly production
20
+ return true;
21
+ }
22
+
23
+ function warnedSet(): Set<string> {
24
+ if (!g.__POWER_UI_WARNED__) g.__POWER_UI_WARNED__ = new Set();
25
+ return g.__POWER_UI_WARNED__;
26
+ }
27
+
28
+ /** Log a unique warning once per key (console.warn). */
29
+ export function devWarnOnce(key: string, message: string): void {
30
+ if (!isDev()) return;
31
+ const set = warnedSet();
32
+ if (set.has(key)) return;
33
+ set.add(key);
34
+ // eslint-disable-next-line no-console
35
+ console.warn(`[Powers] ${message}`);
36
+ }
37
+
38
+ /**
39
+ * After first paint, check that design tokens exist (theme.css imported).
40
+ * Safe to call multiple times.
41
+ */
42
+ export function warnIfThemeMissing(doc: Document = document): void {
43
+ if (!isDev() || typeof doc === "undefined") return;
44
+ queueMicrotask(() => {
45
+ try {
46
+ const cs = doc.defaultView?.getComputedStyle(doc.documentElement);
47
+ const accent = cs?.getPropertyValue("--pu-color-accent")?.trim();
48
+ if (!accent) {
49
+ devWarnOnce(
50
+ "theme-css",
51
+ 'Missing design tokens. Import once at the app root: import "@lab206/ui/theme.css"; then createTheme().bind().',
52
+ );
53
+ }
54
+ } catch {
55
+ /* ignore */
56
+ }
57
+ });
58
+ }
59
+
60
+ /**
61
+ * Heuristic: plain-string `value` on a named control often means the author
62
+ * wrote `value={email()}` (snapshot) instead of `bind={email}` / `value={email}`.
63
+ * Only warns when `bound` is not used.
64
+ */
65
+ export function warnPossibleSnapshotValue(
66
+ control: string,
67
+ opts: { hasBind?: boolean; valueIsPlainString?: boolean },
68
+ ): void {
69
+ if (!isDev() || opts.hasBind || !opts.valueIsPlainString) return;
70
+ devWarnOnce(
71
+ `snapshot-${control}`,
72
+ `${control}: value is a plain string. If you meant a live signal, pass the signal itself: bind={email} or value={email} — not value={email()}.`,
73
+ );
74
+ }
75
+
76
+ /** Enable/disable all Powers dev warnings. */
77
+ export function setDevWarnings(enabled: boolean): void {
78
+ g.__POWER_UI_DEV__ = enabled;
79
+ }
80
+
81
+ /** Install theme check (call once from demo / template main). */
82
+ export function installDevWarnings(): void {
83
+ if (!isDev()) return;
84
+ if (typeof document !== "undefined") {
85
+ warnIfThemeMissing(document);
86
+ }
87
+ }
@@ -0,0 +1,61 @@
1
+ const FOCUSABLE =
2
+ 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])';
3
+
4
+ function listFocusable(root: HTMLElement): HTMLElement[] {
5
+ return [...root.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
6
+ (el) => !el.hasAttribute("disabled") && el.offsetParent !== null,
7
+ );
8
+ }
9
+
10
+ /**
11
+ * Trap Tab focus inside `root` (for Dialog / Command / Drawer).
12
+ * Returns a dispose that removes the listener and restores prior focus.
13
+ */
14
+ export function trapFocus(root: HTMLElement): () => void {
15
+ const doc = root.ownerDocument;
16
+ const prev = doc.activeElement as HTMLElement | null;
17
+
18
+ const focusFirst = () => {
19
+ const items = listFocusable(root);
20
+ (items[0] ?? root).focus?.();
21
+ };
22
+
23
+ // Defer so open animation / mount finishes
24
+ const win = doc.defaultView ?? window;
25
+ const t = win.setTimeout(focusFirst, 0);
26
+
27
+ const onKey = (e: KeyboardEvent) => {
28
+ if (e.key !== "Tab") return;
29
+ const items = listFocusable(root);
30
+ if (items.length === 0) {
31
+ e.preventDefault();
32
+ return;
33
+ }
34
+ const first = items[0]!;
35
+ const last = items[items.length - 1]!;
36
+ const active = doc.activeElement as HTMLElement | null;
37
+ if (e.shiftKey) {
38
+ if (active === first || !root.contains(active)) {
39
+ e.preventDefault();
40
+ last.focus();
41
+ }
42
+ } else if (active === last) {
43
+ e.preventDefault();
44
+ first.focus();
45
+ }
46
+ };
47
+
48
+ doc.addEventListener("keydown", onKey, true);
49
+
50
+ return () => {
51
+ win.clearTimeout(t);
52
+ doc.removeEventListener("keydown", onKey, true);
53
+ if (prev && typeof prev.focus === "function") {
54
+ try {
55
+ prev.focus();
56
+ } catch {
57
+ /* ignore */
58
+ }
59
+ }
60
+ };
61
+ }
package/src/form.ts ADDED
@@ -0,0 +1,269 @@
1
+ /**
2
+ * Lightweight form helpers — pure functions for Field `error` props,
3
+ * two-way bind helpers, and a small `createField` pattern.
4
+ *
5
+ * No schema DSL. Keep validation next to signals.
6
+ *
7
+ * @example Preferred control wiring
8
+ * ```tsx
9
+ * const email = signal("");
10
+ * <Input bind={email} />
11
+ * // or spread: <Input {...bindText(email)} />
12
+ * ```
13
+ *
14
+ * @example Validation
15
+ * ```tsx
16
+ * const emailError = () => firstError(required(email()), emailFormat(email()));
17
+ * <Field label="Email" error={emailError}><Input bind={email} /></Field>
18
+ * ```
19
+ */
20
+
21
+ import { signal, type Signal } from "@lab206/core";
22
+
23
+ export type FieldError = string | undefined | null | false;
24
+
25
+ /** Anything that behaves like a writable signal (Powers signal). */
26
+ export type Bindable<T> = {
27
+ (): T;
28
+ set: (value: T) => void;
29
+ };
30
+
31
+ /** First truthy message wins (empty string / false / null skipped). */
32
+ export function firstError(
33
+ ...msgs: Array<FieldError | (() => FieldError)>
34
+ ): string {
35
+ for (const m of msgs) {
36
+ const v = typeof m === "function" ? m() : m;
37
+ if (v) return String(v);
38
+ }
39
+ return "";
40
+ }
41
+
42
+ export function required(
43
+ value: unknown,
44
+ message = "Required",
45
+ ): FieldError {
46
+ if (value == null) return message;
47
+ if (typeof value === "string" && value.trim() === "") return message;
48
+ if (Array.isArray(value) && value.length === 0) return message;
49
+ return "";
50
+ }
51
+
52
+ export function minLength(
53
+ value: string,
54
+ min: number,
55
+ message?: string,
56
+ ): FieldError {
57
+ if (!value) return "";
58
+ if (value.length < min) {
59
+ return message ?? `At least ${min} characters`;
60
+ }
61
+ return "";
62
+ }
63
+
64
+ export function maxLength(
65
+ value: string,
66
+ max: number,
67
+ message?: string,
68
+ ): FieldError {
69
+ if (!value) return "";
70
+ if (value.length > max) {
71
+ return message ?? `At most ${max} characters`;
72
+ }
73
+ return "";
74
+ }
75
+
76
+ const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
77
+
78
+ export function emailFormat(
79
+ value: string,
80
+ message = "Enter a valid email",
81
+ ): FieldError {
82
+ if (!value || !value.trim()) return "";
83
+ return EMAIL_RE.test(value.trim()) ? "" : message;
84
+ }
85
+
86
+ export function matches(
87
+ value: string,
88
+ other: string,
89
+ message = "Does not match",
90
+ ): FieldError {
91
+ if (!value && !other) return "";
92
+ return value === other ? "" : message;
93
+ }
94
+
95
+ /**
96
+ * Run a map of field → error string. Returns `{ ok, errors }`.
97
+ * Use after submit or whenever you want a full-form snapshot.
98
+ */
99
+ export function validateForm<K extends string>(
100
+ fields: Record<K, () => FieldError>,
101
+ ): { ok: boolean; errors: Partial<Record<K, string>> } {
102
+ const errors: Partial<Record<K, string>> = {};
103
+ let ok = true;
104
+ for (const key of Object.keys(fields) as K[]) {
105
+ const msg = fields[key]!();
106
+ if (msg) {
107
+ errors[key] = String(msg);
108
+ ok = false;
109
+ }
110
+ }
111
+ return { ok, errors };
112
+ }
113
+
114
+ /** Read string value from an input / textarea / select event. */
115
+ export function eventValue(e: Event): string {
116
+ const t = e.currentTarget ?? e.target;
117
+ // Duck-type (works in happy-dom / SSR stubs without instanceof globals)
118
+ if (t && typeof t === "object" && "value" in t) {
119
+ return String((t as { value: unknown }).value ?? "");
120
+ }
121
+ return "";
122
+ }
123
+
124
+ /** Read checked state from a checkbox / switch-like event. */
125
+ export function eventChecked(e: Event): boolean {
126
+ const t = e.currentTarget ?? e.target;
127
+ if (t && typeof t === "object" && "checked" in t) {
128
+ return !!(t as { checked: unknown }).checked;
129
+ }
130
+ return false;
131
+ }
132
+
133
+ /**
134
+ * Props to spread onto `Input` / `Textarea` for two-way signal binding.
135
+ *
136
+ * Prefer the control’s `bind={signal}` prop when available.
137
+ * Named `bindInput` (not `bindText`) so it doesn’t clash with `@lab206/dom`’s
138
+ * DOM `bindText` helper.
139
+ */
140
+ export function bindInput(sig: Bindable<string>): {
141
+ value: Bindable<string>;
142
+ onInput: (e: Event) => void;
143
+ } {
144
+ return {
145
+ value: sig,
146
+ onInput: (e: Event) => {
147
+ sig.set(eventValue(e));
148
+ },
149
+ };
150
+ }
151
+
152
+ /** Alias of bindInput. */
153
+ export const bindString = bindInput;
154
+
155
+ /**
156
+ * Props to spread onto `Select` for two-way signal binding.
157
+ * Prefer `bind={signal}` on Select when available.
158
+ */
159
+ export function bindSelect(sig: Bindable<string>): {
160
+ value: Bindable<string>;
161
+ onChange: (e: Event) => void;
162
+ } {
163
+ return {
164
+ value: sig,
165
+ onChange: (e: Event) => {
166
+ sig.set(eventValue(e));
167
+ },
168
+ };
169
+ }
170
+
171
+ /**
172
+ * Adapt a typed string-union signal (`Signal<"a" | "b">`) for `<Select bind={…} />`.
173
+ */
174
+ export function asSelectBind<T extends string>(sig: {
175
+ (): T;
176
+ set: (value: T) => void;
177
+ }): Bindable<string> {
178
+ const bound = (() => sig()) as unknown as Bindable<string>;
179
+ bound.set = (value: string) => {
180
+ sig.set(value as T);
181
+ };
182
+ return bound;
183
+ }
184
+
185
+ /**
186
+ * Props for checkbox / switch patterns that use `checked` + `onChange(boolean)`.
187
+ */
188
+ export function bindChecked(sig: Bindable<boolean>): {
189
+ checked: Bindable<boolean>;
190
+ onChange: (checked: boolean) => void;
191
+ } {
192
+ return {
193
+ checked: sig,
194
+ onChange: (checked: boolean) => {
195
+ sig.set(checked);
196
+ },
197
+ };
198
+ }
199
+
200
+ export type CreateFieldOptions = {
201
+ /** Initial string value */
202
+ initial?: string;
203
+ /**
204
+ * Validate when touched (or always if `validateWhen: "always"`).
205
+ * Return a message or falsy.
206
+ */
207
+ validate?: (value: string) => FieldError;
208
+ /** Default `"touched"` — only show errors after blur/submit. */
209
+ validateWhen?: "touched" | "always";
210
+ };
211
+
212
+ export type FieldHandle = {
213
+ /** Writable signal — pass to `bind={field.value}` or `value={field.value}` */
214
+ value: Signal<string>;
215
+ touched: Signal<boolean>;
216
+ /** Accessor for Field `error={field.error}` */
217
+ error: () => string;
218
+ /** Mark touched (call on blur or before submit) */
219
+ touch: () => void;
220
+ /** Reset value + untouched */
221
+ reset: (next?: string) => void;
222
+ /** Current string (sugar for value()) */
223
+ get: () => string;
224
+ set: (v: string) => void;
225
+ };
226
+
227
+ /**
228
+ * One field’s state: value signal + touched + error accessor.
229
+ *
230
+ * @example
231
+ * ```tsx
232
+ * const name = createField({
233
+ * validate: (v) => required(v, "Name required"),
234
+ * });
235
+ *
236
+ * <Field label="Name" required error={name.error}>
237
+ * <Input bind={name.value} onBlur={name.touch} />
238
+ * </Field>
239
+ *
240
+ * // on submit:
241
+ * name.touch();
242
+ * if (name.error()) return;
243
+ * ```
244
+ */
245
+ export function createField(options: CreateFieldOptions = {}): FieldHandle {
246
+ const initial = options.initial ?? "";
247
+ const value = signal(initial);
248
+ const touched = signal(false);
249
+ const when = options.validateWhen ?? "touched";
250
+
251
+ const error = (): string => {
252
+ if (when === "touched" && !touched()) return "";
253
+ if (!options.validate) return "";
254
+ return firstError(options.validate(value()));
255
+ };
256
+
257
+ return {
258
+ value,
259
+ touched,
260
+ error,
261
+ touch: () => touched.set(true),
262
+ reset: (next = initial) => {
263
+ value.set(next);
264
+ touched.set(false);
265
+ },
266
+ get: () => value(),
267
+ set: (v: string) => value.set(v),
268
+ };
269
+ }