@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,219 @@
1
+ import { effect } from "@lab206/core";
2
+ import { component, mergeProps, type ComponentProps } from "@lab206/dom";
3
+ import { cx } from "../utils.js";
4
+ import { attachOverlay } from "../overlay.js";
5
+ import { readBool, type MaybeReactive } from "../reactive.js";
6
+
7
+ export type PopoverProps = {
8
+ /** Controlled open state (boolean, signal, or accessor) */
9
+ open: MaybeReactive<boolean>;
10
+ onOpenChange?: (open: boolean) => void;
11
+ /** Trigger content (usually a Button) */
12
+ trigger: unknown;
13
+ /** Panel body */
14
+ children?: unknown;
15
+ /** Preferred alignment under the trigger */
16
+ align?: "start" | "center" | "end";
17
+ class?: MaybeReactive<string>;
18
+ };
19
+
20
+ const styles = `
21
+ .pu-popover {
22
+ position: relative;
23
+ display: inline-flex;
24
+ vertical-align: middle;
25
+ max-width: 100%;
26
+ }
27
+ .pu-popover--open {
28
+ z-index: var(--pu-z-overlay);
29
+ }
30
+ .pu-popover__trigger {
31
+ display: inline-flex;
32
+ max-width: 100%;
33
+ cursor: pointer;
34
+ }
35
+ .pu-popover__panel {
36
+ position: fixed;
37
+ z-index: var(--pu-z-overlay);
38
+ min-width: 12rem;
39
+ max-width: min(20rem, calc(100vw - 1.5rem));
40
+ max-height: min(70vh, 24rem);
41
+ overflow: auto;
42
+ padding: var(--pu-space-3);
43
+ border-radius: var(--pu-radius-lg);
44
+ background: var(--pu-color-surface-elevated);
45
+ color: var(--pu-color-text);
46
+ border: 1px solid var(--pu-color-border);
47
+ box-shadow: var(--pu-shadow-float);
48
+ opacity: 0;
49
+ visibility: hidden;
50
+ pointer-events: none;
51
+ transform: translateY(6px) scale(0.98);
52
+ transform-origin: top center;
53
+ transition:
54
+ opacity var(--pu-duration) var(--pu-ease-out),
55
+ transform var(--pu-duration) var(--pu-ease-out),
56
+ visibility var(--pu-duration) var(--pu-ease-out);
57
+ }
58
+ .pu-popover__panel[data-side="top"] {
59
+ transform: translateY(-6px) scale(0.98);
60
+ transform-origin: bottom center;
61
+ }
62
+ .pu-popover--open .pu-popover__panel {
63
+ opacity: 1;
64
+ visibility: visible;
65
+ pointer-events: auto;
66
+ transform: translateY(0) scale(1);
67
+ }
68
+ @media (prefers-reduced-motion: reduce) {
69
+ .pu-popover__panel { transition: none; transform: none; }
70
+ .pu-popover--open .pu-popover__panel { transform: none; }
71
+ }
72
+ `;
73
+
74
+ function ensureStyles(doc: Document = document) {
75
+ if (typeof doc === "undefined") return;
76
+ if (doc.querySelector('style[data-pu-ui="popover"]')) return;
77
+ const el = doc.createElement("style");
78
+ el.setAttribute("data-pu-ui", "popover");
79
+ el.textContent = styles;
80
+ doc.head.appendChild(el);
81
+ }
82
+
83
+ /**
84
+ * Anchored floating panel. Control with `open` + `onOpenChange`.
85
+ * Escape + outside dismiss via shared `attachOverlay` (stacked with modals).
86
+ */
87
+ export const Popover = component((raw: PopoverProps) => {
88
+ ensureStyles();
89
+ const props = mergeProps({ align: "start" as const }, raw) as ComponentProps<
90
+ PopoverProps & { align: "start" | "center" | "end" }
91
+ >;
92
+
93
+ const isOpen = () => readBool(props.open as MaybeReactive<boolean>);
94
+
95
+ let rootEl: HTMLElement | null = null;
96
+ let panelEl: HTMLElement | null = null;
97
+ let triggerEl: HTMLElement | null = null;
98
+
99
+ const placePanel = () => {
100
+ if (!rootEl || !panelEl || !triggerEl || !isOpen()) return;
101
+ const doc = rootEl.ownerDocument;
102
+ const win = doc.defaultView ?? window;
103
+ const gap = 8;
104
+ const pad = 8;
105
+ const rect = triggerEl.getBoundingClientRect();
106
+ const vw = win.innerWidth;
107
+ const vh = win.innerHeight;
108
+
109
+ panelEl.style.maxHeight = "";
110
+ const naturalH = panelEl.scrollHeight || panelEl.offsetHeight || 160;
111
+ const pw = Math.min(
112
+ panelEl.offsetWidth || 192,
113
+ Math.max(pad * 2, vw - pad * 2),
114
+ );
115
+
116
+ const spaceBelow = vh - rect.bottom - gap - pad;
117
+ const spaceAbove = rect.top - gap - pad;
118
+ const placeAbove =
119
+ spaceBelow < Math.min(naturalH, 120) && spaceAbove > spaceBelow;
120
+
121
+ const available = Math.max(96, placeAbove ? spaceAbove : spaceBelow);
122
+ const maxH = Math.min(naturalH, available, vh * 0.7);
123
+ panelEl.style.maxHeight = `${Math.round(maxH)}px`;
124
+
125
+ const ph = Math.min(panelEl.offsetHeight || maxH, maxH);
126
+
127
+ let top: number;
128
+ if (placeAbove) {
129
+ top = rect.top - gap - ph;
130
+ panelEl.dataset.side = "top";
131
+ } else {
132
+ top = rect.bottom + gap;
133
+ panelEl.dataset.side = "bottom";
134
+ }
135
+ top = Math.min(Math.max(pad, top), Math.max(pad, vh - ph - pad));
136
+
137
+ let left = rect.left;
138
+ if (props.align === "center") {
139
+ left = rect.left + rect.width / 2 - pw / 2;
140
+ } else if (props.align === "end") {
141
+ left = rect.right - pw;
142
+ }
143
+ left = Math.min(Math.max(pad, left), Math.max(pad, vw - pw - pad));
144
+
145
+ panelEl.style.top = `${Math.round(top)}px`;
146
+ panelEl.style.left = `${Math.round(left)}px`;
147
+ panelEl.style.width = "max-content";
148
+ panelEl.style.minWidth = `${Math.min(pw, Math.max(rect.width, 10.5 * 16))}px`;
149
+ };
150
+
151
+ effect(() => {
152
+ if (!isOpen()) return;
153
+ return attachOverlay({
154
+ getRoot: () => rootEl,
155
+ onClose: () => props.onOpenChange?.(false),
156
+ escape: true,
157
+ dismissOutside: true,
158
+ onAttach: ({ doc, win }) => {
159
+ ensureStyles(doc);
160
+ const onReposition = () => placePanel();
161
+ const placeTimer = win.setTimeout(() => {
162
+ placePanel();
163
+ win.setTimeout(placePanel, 16);
164
+ }, 0);
165
+ win.addEventListener("resize", onReposition);
166
+ doc.addEventListener("scroll", onReposition, true);
167
+ win.addEventListener("scroll", onReposition, true);
168
+ return () => {
169
+ win.clearTimeout(placeTimer);
170
+ win.removeEventListener("resize", onReposition);
171
+ doc.removeEventListener("scroll", onReposition, true);
172
+ win.removeEventListener("scroll", onReposition, true);
173
+ };
174
+ },
175
+ });
176
+ });
177
+
178
+ return (
179
+ <div
180
+ class={() =>
181
+ cx(
182
+ "pu-popover",
183
+ isOpen() && "pu-popover--open",
184
+ typeof props.class === "function"
185
+ ? (props.class as () => string)()
186
+ : props.class,
187
+ )
188
+ }
189
+ ref={(el) => {
190
+ rootEl = el;
191
+ ensureStyles(el.ownerDocument);
192
+ }}
193
+ >
194
+ <div
195
+ class="pu-popover__trigger"
196
+ ref={(el) => {
197
+ triggerEl = el;
198
+ }}
199
+ onClick={(e: MouseEvent) => {
200
+ e.stopPropagation();
201
+ props.onOpenChange?.(!isOpen());
202
+ }}
203
+ >
204
+ {props.trigger as never}
205
+ </div>
206
+ <div
207
+ class="pu-popover__panel"
208
+ role="dialog"
209
+ aria-modal="false"
210
+ aria-hidden={() => (!isOpen() ? "true" : "false")}
211
+ ref={(el) => {
212
+ panelEl = el;
213
+ }}
214
+ >
215
+ {props.children as never}
216
+ </div>
217
+ </div>
218
+ );
219
+ });
@@ -0,0 +1,108 @@
1
+ import { component, mergeProps, type ComponentProps } from "@lab206/dom";
2
+ import { cx } from "../utils.js";
3
+
4
+ export type ProgressProps = {
5
+ /** 0–100 */
6
+ value?: number | (() => number);
7
+ /** Accessible label */
8
+ label?: string;
9
+ size?: "sm" | "md";
10
+ class?: string | (() => string);
11
+ };
12
+
13
+ const styles = `
14
+ .pu-progress {
15
+ display: flex;
16
+ flex-direction: column;
17
+ gap: var(--pu-space-2);
18
+ width: 100%;
19
+ min-width: 0;
20
+ }
21
+ .pu-progress__meta {
22
+ display: flex;
23
+ justify-content: space-between;
24
+ align-items: baseline;
25
+ gap: var(--pu-space-3);
26
+ font-size: var(--pu-text-xs);
27
+ color: var(--pu-color-text-muted);
28
+ font-weight: var(--pu-font-medium);
29
+ }
30
+ .pu-progress__track {
31
+ width: 100%;
32
+ overflow: hidden;
33
+ border-radius: var(--pu-radius-full);
34
+ background: var(--pu-color-surface-sunken);
35
+ border: 1px solid var(--pu-color-border);
36
+ }
37
+ .pu-progress--sm .pu-progress__track { height: 0.4rem; }
38
+ .pu-progress--md .pu-progress__track { height: 0.55rem; }
39
+ .pu-progress__bar {
40
+ height: 100%;
41
+ border-radius: inherit;
42
+ background: linear-gradient(
43
+ 90deg,
44
+ var(--pu-brand-500),
45
+ var(--pu-sage-400)
46
+ );
47
+ transition: width var(--pu-duration-slow) var(--pu-ease-out);
48
+ box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 10%, transparent);
49
+ }
50
+ `;
51
+
52
+ let injected = false;
53
+ function ensureStyles() {
54
+ if (injected || typeof document === "undefined") return;
55
+ injected = true;
56
+ const el = document.createElement("style");
57
+ el.setAttribute("data-pu-ui", "progress");
58
+ el.textContent = styles;
59
+ document.head.appendChild(el);
60
+ }
61
+
62
+ export const Progress = component((raw: ProgressProps) => {
63
+ ensureStyles();
64
+ const props = mergeProps(
65
+ { value: 0, size: "md" as const },
66
+ raw,
67
+ ) as ComponentProps<ProgressProps & { value: number | (() => number); size: "sm" | "md" }>;
68
+
69
+ const pct = () => {
70
+ const v =
71
+ typeof props.value === "function"
72
+ ? (props.value as () => number)()
73
+ : (props.value as number);
74
+ return Math.max(0, Math.min(100, Number.isFinite(v) ? v : 0));
75
+ };
76
+
77
+ return (
78
+ <div
79
+ class={() =>
80
+ cx(
81
+ "pu-progress",
82
+ `pu-progress--${props.size}`,
83
+ typeof props.class === "function" ? props.class() : props.class,
84
+ )
85
+ }
86
+ >
87
+ {props.label ? (
88
+ <div class="pu-progress__meta">
89
+ <span>{props.label}</span>
90
+ <span>{() => `${Math.round(pct())}%`}</span>
91
+ </div>
92
+ ) : null}
93
+ <div
94
+ class="pu-progress__track"
95
+ role="progressbar"
96
+ aria-valuemin={0}
97
+ aria-valuemax={100}
98
+ aria-valuenow={() => Math.round(pct())}
99
+ aria-label={props.label}
100
+ >
101
+ <div
102
+ class="pu-progress__bar"
103
+ style={() => ({ width: `${pct()}%` })}
104
+ />
105
+ </div>
106
+ </div>
107
+ );
108
+ });
@@ -0,0 +1,161 @@
1
+ import { For, component, mergeProps, type ComponentProps } from "@lab206/dom";
2
+ import { cx } from "../utils.js";
3
+ import { createStyleSheet } from "../styles.js";
4
+
5
+ export type RadioOption = {
6
+ value: string;
7
+ label: string;
8
+ disabled?: boolean;
9
+ };
10
+
11
+ export type RadioGroupProps = {
12
+ options: RadioOption[] | (() => RadioOption[]);
13
+ value?: string | (() => string);
14
+ name?: string;
15
+ disabled?: boolean | (() => boolean);
16
+ onChange?: (value: string) => void;
17
+ orientation?: "vertical" | "horizontal";
18
+ class?: string | (() => string);
19
+ };
20
+
21
+ const ensure = createStyleSheet(
22
+ "radio",
23
+ `
24
+ .pu-radio-group {
25
+ display: flex;
26
+ flex-direction: column;
27
+ gap: 0.5rem;
28
+ }
29
+ .pu-radio-group--horizontal {
30
+ flex-direction: row;
31
+ flex-wrap: wrap;
32
+ gap: 0.75rem 1.25rem;
33
+ }
34
+ .pu-radio {
35
+ display: inline-flex;
36
+ align-items: center;
37
+ gap: var(--pu-space-2);
38
+ cursor: pointer;
39
+ user-select: none;
40
+ font-size: var(--pu-text-sm);
41
+ color: var(--pu-color-text);
42
+ }
43
+ .pu-radio[data-disabled="true"] {
44
+ opacity: 0.55;
45
+ cursor: not-allowed;
46
+ }
47
+ .pu-radio__dot {
48
+ width: 1.1rem;
49
+ height: 1.1rem;
50
+ border-radius: 50%;
51
+ border: 1.5px solid var(--pu-color-control-border, var(--pu-color-border-strong));
52
+ background: var(--pu-color-surface);
53
+ display: grid;
54
+ place-items: center;
55
+ flex-shrink: 0;
56
+ transition:
57
+ border-color var(--pu-duration) var(--pu-ease),
58
+ background var(--pu-duration) var(--pu-ease);
59
+ }
60
+ .pu-radio:hover:not([data-disabled="true"]) .pu-radio__dot {
61
+ border-color: var(--pu-color-border-strong);
62
+ }
63
+ .pu-radio[data-checked="true"] .pu-radio__dot {
64
+ border-color: var(--pu-color-accent);
65
+ }
66
+ .pu-radio__dot::after {
67
+ content: "";
68
+ width: 0.45rem;
69
+ height: 0.45rem;
70
+ border-radius: 50%;
71
+ background: var(--pu-color-accent);
72
+ opacity: 0;
73
+ transform: scale(0.5);
74
+ transition:
75
+ opacity var(--pu-duration) var(--pu-ease),
76
+ transform var(--pu-duration) var(--pu-ease);
77
+ }
78
+ .pu-radio[data-checked="true"] .pu-radio__dot::after {
79
+ opacity: 1;
80
+ transform: scale(1);
81
+ }
82
+ .pu-radio__input {
83
+ position: absolute;
84
+ opacity: 0;
85
+ width: 0;
86
+ height: 0;
87
+ }
88
+ .pu-radio__input:focus-visible + .pu-radio__dot {
89
+ outline: none;
90
+ box-shadow:
91
+ 0 0 0 2px var(--pu-color-surface),
92
+ 0 0 0 4px color-mix(in srgb, var(--pu-color-focus) 55%, transparent);
93
+ }
94
+ @media (prefers-reduced-motion: reduce) {
95
+ .pu-radio__dot,
96
+ .pu-radio__dot::after { transition: none; }
97
+ }
98
+ `,
99
+ );
100
+
101
+ /** Radio group — single selection from options. */
102
+ export const RadioGroup = component((raw: RadioGroupProps) => {
103
+ ensure();
104
+ const props = mergeProps(
105
+ { orientation: "vertical" as const },
106
+ raw,
107
+ ) as ComponentProps<RadioGroupProps & { orientation: "vertical" | "horizontal" }>;
108
+
109
+ const name =
110
+ props.name ?? `pu-radio-${Math.random().toString(36).slice(2, 9)}`;
111
+
112
+ const current = () =>
113
+ typeof props.value === "function"
114
+ ? (props.value as () => string)()
115
+ : (props.value ?? "");
116
+ const groupDisabled = () =>
117
+ !!(typeof props.disabled === "function" ? props.disabled() : props.disabled);
118
+
119
+ const getOptions = () =>
120
+ typeof props.options === "function"
121
+ ? (props.options as () => RadioOption[])()
122
+ : (props.options ?? []);
123
+
124
+ return (
125
+ <div
126
+ class={() =>
127
+ cx(
128
+ "pu-radio-group",
129
+ props.orientation === "horizontal" && "pu-radio-group--horizontal",
130
+ typeof props.class === "function" ? props.class() : props.class,
131
+ )
132
+ }
133
+ role="radiogroup"
134
+ ref={(el) => ensure(el.ownerDocument)}
135
+ >
136
+ <For each={getOptions}>
137
+ {(opt) => (
138
+ <label
139
+ class="pu-radio"
140
+ data-checked={() => (current() === opt().value ? "true" : "false")}
141
+ data-disabled={() =>
142
+ groupDisabled() || opt().disabled ? "true" : "false"
143
+ }
144
+ >
145
+ <input
146
+ class="pu-radio__input"
147
+ type="radio"
148
+ name={name}
149
+ value={() => opt().value}
150
+ checked={() => current() === opt().value}
151
+ disabled={() => groupDisabled() || !!opt().disabled}
152
+ onChange={() => props.onChange?.(opt().value)}
153
+ />
154
+ <span class="pu-radio__dot" aria-hidden="true" />
155
+ <span>{() => opt().label}</span>
156
+ </label>
157
+ )}
158
+ </For>
159
+ </div>
160
+ );
161
+ });
@@ -0,0 +1,58 @@
1
+ import { component, type ComponentProps } from "@lab206/dom";
2
+ import { cx } from "../utils.js";
3
+ import { createStyleSheet } from "../styles.js";
4
+
5
+ export type ScrollAreaProps = {
6
+ /** Max height (CSS length) */
7
+ maxHeight?: string;
8
+ class?: string | (() => string);
9
+ children?: unknown;
10
+ };
11
+
12
+ const ensure = createStyleSheet(
13
+ "scroll-area",
14
+ `
15
+ .pu-scroll {
16
+ overflow: auto;
17
+ overscroll-behavior: contain;
18
+ scrollbar-gutter: stable;
19
+ border-radius: var(--pu-radius-md);
20
+ }
21
+ .pu-scroll::-webkit-scrollbar {
22
+ width: 8px;
23
+ height: 8px;
24
+ }
25
+ .pu-scroll::-webkit-scrollbar-thumb {
26
+ background: color-mix(in srgb, var(--pu-color-text-muted) 35%, transparent);
27
+ border-radius: 999px;
28
+ }
29
+ .pu-scroll::-webkit-scrollbar-thumb:hover {
30
+ background: color-mix(in srgb, var(--pu-color-text-muted) 55%, transparent);
31
+ }
32
+ .pu-scroll::-webkit-scrollbar-track {
33
+ background: transparent;
34
+ }
35
+ `,
36
+ );
37
+
38
+ /** Scrollable region with refined scrollbars. */
39
+ export const ScrollArea = component((raw: ScrollAreaProps) => {
40
+ ensure();
41
+ const props = raw as ComponentProps<ScrollAreaProps>;
42
+ return (
43
+ <div
44
+ class={() =>
45
+ cx(
46
+ "pu-scroll",
47
+ typeof props.class === "function" ? props.class() : props.class,
48
+ )
49
+ }
50
+ style={() =>
51
+ props.maxHeight ? { maxHeight: props.maxHeight } : undefined
52
+ }
53
+ ref={(el) => ensure(el.ownerDocument)}
54
+ >
55
+ {props.children as never}
56
+ </div>
57
+ );
58
+ });
@@ -0,0 +1,128 @@
1
+ import { effect, type Signal } from "@lab206/core";
2
+ import { component, mergeProps, type ComponentProps } from "@lab206/dom";
3
+ import { cx } from "../utils.js";
4
+ import { readBool, readProp, type MaybeReactive } from "../reactive.js";
5
+ import type { Bindable } from "../form.js";
6
+
7
+ export type SelectOption = { value: string; label: string; disabled?: boolean };
8
+
9
+ export type SelectProps = {
10
+ value?: MaybeReactive<string>;
11
+ /** Two-way bind a string signal — preferred over value + onChange. */
12
+ bind?: Bindable<string> | Signal<string>;
13
+ options: MaybeReactive<SelectOption[]>;
14
+ /** Shown as a disabled first option when value is empty (optional). */
15
+ placeholder?: string;
16
+ disabled?: MaybeReactive<boolean>;
17
+ class?: MaybeReactive<string>;
18
+ onChange?: (e: Event) => void;
19
+ id?: string;
20
+ "aria-label"?: string;
21
+ };
22
+
23
+ const styles = `
24
+ .pu-select {
25
+ width: 100%;
26
+ height: var(--pu-control-h-md);
27
+ padding: 0 calc(var(--pu-control-px) + 1rem) 0 var(--pu-control-px);
28
+ border-radius: var(--pu-radius-md);
29
+ border: 1px solid var(--pu-color-control-border, var(--pu-color-border));
30
+ background-color: var(--pu-color-surface);
31
+ color: var(--pu-color-text);
32
+ appearance: none;
33
+ background-image: linear-gradient(45deg, transparent 50%, var(--pu-color-text-muted) 50%),
34
+ linear-gradient(135deg, var(--pu-color-text-muted) 50%, transparent 50%);
35
+ background-position: calc(100% - 14px) calc(50% - 3px), calc(100% - 9px) calc(50% - 3px);
36
+ background-size: 5px 5px, 5px 5px;
37
+ background-repeat: no-repeat;
38
+ transition: border-color var(--pu-duration) var(--pu-ease), box-shadow var(--pu-duration) var(--pu-ease);
39
+ }
40
+ .pu-select:hover:not(:disabled) {
41
+ border-color: color-mix(in srgb, var(--pu-color-border) 60%, var(--pu-color-text-muted));
42
+ }
43
+ .pu-select:focus {
44
+ outline: none;
45
+ border-color: color-mix(in srgb, var(--pu-color-focus) 55%, var(--pu-color-border));
46
+ box-shadow: 0 0 0 3px color-mix(in srgb, var(--pu-color-focus) 18%, transparent);
47
+ }
48
+ .pu-select:disabled { opacity: 0.55; cursor: not-allowed; }
49
+ `;
50
+
51
+ let injected = false;
52
+ function ensureStyles() {
53
+ if (injected || typeof document === "undefined") return;
54
+ injected = true;
55
+ const el = document.createElement("style");
56
+ el.setAttribute("data-pu-ui", "select");
57
+ el.textContent = styles;
58
+ document.head.appendChild(el);
59
+ }
60
+
61
+ /** Native select. Prefer `<Select bind={status} options={…} />`. */
62
+ export const Select = component((raw: SelectProps) => {
63
+ ensureStyles();
64
+ const props = mergeProps({}, raw) as ComponentProps<SelectProps>;
65
+ const bound = raw.bind;
66
+
67
+ const selectEl = document.createElement("select");
68
+ if (props.id) selectEl.id = props.id;
69
+ if (props["aria-label"]) selectEl.setAttribute("aria-label", props["aria-label"]);
70
+
71
+ selectEl.addEventListener("change", (e) => {
72
+ if (bound) {
73
+ const t = (e.currentTarget ?? e.target) as HTMLSelectElement | null;
74
+ if (t && "value" in t) bound.set(t.value);
75
+ }
76
+ props.onChange?.(e);
77
+ });
78
+
79
+ effect(() => {
80
+ // mergeProps unwraps accessors on read; readProp stays defensive.
81
+ selectEl.className = cx("pu-select", readProp(props.class as MaybeReactive<string>));
82
+ selectEl.disabled = readBool(props.disabled as MaybeReactive<boolean>);
83
+
84
+ const opts = readProp(props.options as MaybeReactive<SelectOption[]>) ?? [];
85
+ const current = bound
86
+ ? readProp(bound as MaybeReactive<string>)
87
+ : readProp(props.value as MaybeReactive<string>);
88
+ const prev = selectEl.value;
89
+ const placeholder = raw.placeholder;
90
+
91
+ selectEl.innerHTML = "";
92
+ const values = new Set<string>();
93
+
94
+ if (placeholder) {
95
+ const ph = document.createElement("option");
96
+ ph.value = "";
97
+ ph.textContent = placeholder;
98
+ ph.disabled = true;
99
+ // Keep selectable only when nothing chosen yet so the label can show
100
+ ph.hidden = !!(current && current !== "");
101
+ selectEl.appendChild(ph);
102
+ values.add("");
103
+ }
104
+
105
+ for (const opt of opts) {
106
+ const o = document.createElement("option");
107
+ o.value = opt.value;
108
+ o.textContent = opt.label;
109
+ if (opt.disabled) o.disabled = true;
110
+ selectEl.appendChild(o);
111
+ values.add(opt.value);
112
+ }
113
+
114
+ // Prefer controlled value when it exists in the list; else keep prev; else first option.
115
+ // Avoid browser “blank select” when options shrink (reactive project/client filters).
116
+ let next = "";
117
+ if (current !== undefined && values.has(current)) {
118
+ next = current;
119
+ } else if (prev && values.has(prev)) {
120
+ next = prev;
121
+ } else if (opts[0]) {
122
+ next = opts[0].value;
123
+ }
124
+ if (selectEl.value !== next) selectEl.value = next;
125
+ });
126
+
127
+ return selectEl;
128
+ });