@c9up/nebula 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 +21 -0
  2. package/README.md +256 -0
  3. package/dist/adapters/css.d.ts +33 -0
  4. package/dist/adapters/css.js +67 -0
  5. package/dist/adapters/index.d.ts +16 -0
  6. package/dist/adapters/index.js +27 -0
  7. package/dist/adapters/tailwind.d.ts +25 -0
  8. package/dist/adapters/tailwind.js +122 -0
  9. package/dist/adapters/types.d.ts +49 -0
  10. package/dist/adapters/types.js +21 -0
  11. package/dist/adapters/unocss.d.ts +17 -0
  12. package/dist/adapters/unocss.js +107 -0
  13. package/dist/atoms/AspectRatio.d.ts +17 -0
  14. package/dist/atoms/AspectRatio.js +19 -0
  15. package/dist/atoms/Avatar.d.ts +23 -0
  16. package/dist/atoms/Avatar.js +46 -0
  17. package/dist/atoms/Badge.d.ts +25 -0
  18. package/dist/atoms/Badge.js +28 -0
  19. package/dist/atoms/Button.d.ts +58 -0
  20. package/dist/atoms/Button.js +67 -0
  21. package/dist/atoms/Checkbox.d.ts +41 -0
  22. package/dist/atoms/Checkbox.js +47 -0
  23. package/dist/atoms/Input.d.ts +36 -0
  24. package/dist/atoms/Input.js +50 -0
  25. package/dist/atoms/Kbd.d.ts +14 -0
  26. package/dist/atoms/Kbd.js +17 -0
  27. package/dist/atoms/Label.d.ts +24 -0
  28. package/dist/atoms/Label.js +26 -0
  29. package/dist/atoms/Marker.d.ts +42 -0
  30. package/dist/atoms/Marker.js +55 -0
  31. package/dist/atoms/NativeSelect.d.ts +43 -0
  32. package/dist/atoms/NativeSelect.js +81 -0
  33. package/dist/atoms/Progress.d.ts +21 -0
  34. package/dist/atoms/Progress.js +48 -0
  35. package/dist/atoms/ScrollArea.d.ts +28 -0
  36. package/dist/atoms/ScrollArea.js +37 -0
  37. package/dist/atoms/Separator.d.ts +16 -0
  38. package/dist/atoms/Separator.js +21 -0
  39. package/dist/atoms/Skeleton.d.ts +13 -0
  40. package/dist/atoms/Skeleton.js +18 -0
  41. package/dist/atoms/Slider.d.ts +30 -0
  42. package/dist/atoms/Slider.js +40 -0
  43. package/dist/atoms/Spinner.d.ts +17 -0
  44. package/dist/atoms/Spinner.js +20 -0
  45. package/dist/atoms/Switch.d.ts +24 -0
  46. package/dist/atoms/Switch.js +41 -0
  47. package/dist/atoms/Textarea.d.ts +28 -0
  48. package/dist/atoms/Textarea.js +36 -0
  49. package/dist/atoms/Toggle.d.ts +38 -0
  50. package/dist/atoms/Toggle.js +45 -0
  51. package/dist/atoms/index.d.ts +32 -0
  52. package/dist/atoms/index.js +32 -0
  53. package/dist/cli/add.d.ts +44 -0
  54. package/dist/cli/add.js +99 -0
  55. package/dist/cli/index.d.ts +10 -0
  56. package/dist/cli/index.js +10 -0
  57. package/dist/cli/registry.d.ts +31 -0
  58. package/dist/cli/registry.js +83 -0
  59. package/dist/config.d.ts +81 -0
  60. package/dist/config.js +43 -0
  61. package/dist/configure.d.ts +34 -0
  62. package/dist/configure.js +85 -0
  63. package/dist/index.d.ts +41 -0
  64. package/dist/index.js +41 -0
  65. package/dist/lib/children.d.ts +28 -0
  66. package/dist/lib/children.js +27 -0
  67. package/dist/lib/cn.d.ts +13 -0
  68. package/dist/lib/cn.js +13 -0
  69. package/dist/lib/cva.d.ts +83 -0
  70. package/dist/lib/cva.js +178 -0
  71. package/dist/lib/icons.d.ts +44 -0
  72. package/dist/lib/icons.js +79 -0
  73. package/dist/lib/id.d.ts +31 -0
  74. package/dist/lib/id.js +41 -0
  75. package/dist/lib/index.d.ts +12 -0
  76. package/dist/lib/index.js +12 -0
  77. package/dist/lib/motion.d.ts +31 -0
  78. package/dist/lib/motion.js +39 -0
  79. package/dist/lib/props.d.ts +48 -0
  80. package/dist/lib/props.js +59 -0
  81. package/dist/lib/styled.d.ts +31 -0
  82. package/dist/lib/styled.js +39 -0
  83. package/dist/molecules/Accordion.d.ts +35 -0
  84. package/dist/molecules/Accordion.js +117 -0
  85. package/dist/molecules/Alert.d.ts +30 -0
  86. package/dist/molecules/Alert.js +35 -0
  87. package/dist/molecules/Attachment.d.ts +39 -0
  88. package/dist/molecules/Attachment.js +103 -0
  89. package/dist/molecules/Breadcrumb.d.ts +29 -0
  90. package/dist/molecules/Breadcrumb.js +73 -0
  91. package/dist/molecules/Bubble.d.ts +56 -0
  92. package/dist/molecules/Bubble.js +92 -0
  93. package/dist/molecules/ButtonGroup.d.ts +23 -0
  94. package/dist/molecules/ButtonGroup.js +30 -0
  95. package/dist/molecules/Card.d.ts +17 -0
  96. package/dist/molecules/Card.js +17 -0
  97. package/dist/molecules/Collapsible.d.ts +40 -0
  98. package/dist/molecules/Collapsible.js +71 -0
  99. package/dist/molecules/Empty.d.ts +16 -0
  100. package/dist/molecules/Empty.js +16 -0
  101. package/dist/molecules/Field.d.ts +54 -0
  102. package/dist/molecules/Field.js +82 -0
  103. package/dist/molecules/InputGroup.d.ts +33 -0
  104. package/dist/molecules/InputGroup.js +35 -0
  105. package/dist/molecules/InputOTP.d.ts +33 -0
  106. package/dist/molecules/InputOTP.js +140 -0
  107. package/dist/molecules/Item.d.ts +41 -0
  108. package/dist/molecules/Item.js +48 -0
  109. package/dist/molecules/Message.d.ts +43 -0
  110. package/dist/molecules/Message.js +82 -0
  111. package/dist/molecules/Pagination.d.ts +41 -0
  112. package/dist/molecules/Pagination.js +144 -0
  113. package/dist/molecules/RadioGroup.d.ts +32 -0
  114. package/dist/molecules/RadioGroup.js +65 -0
  115. package/dist/molecules/Resizable.d.ts +37 -0
  116. package/dist/molecules/Resizable.js +136 -0
  117. package/dist/molecules/Table.d.ts +37 -0
  118. package/dist/molecules/Table.js +78 -0
  119. package/dist/molecules/Tabs.d.ts +37 -0
  120. package/dist/molecules/Tabs.js +109 -0
  121. package/dist/molecules/ToggleGroup.d.ts +37 -0
  122. package/dist/molecules/ToggleGroup.js +68 -0
  123. package/dist/molecules/Typography.d.ts +26 -0
  124. package/dist/molecules/Typography.js +67 -0
  125. package/dist/molecules/index.d.ts +37 -0
  126. package/dist/molecules/index.js +37 -0
  127. package/dist/organisms/AlertDialog.d.ts +40 -0
  128. package/dist/organisms/AlertDialog.js +104 -0
  129. package/dist/organisms/Calendar.d.ts +96 -0
  130. package/dist/organisms/Calendar.js +353 -0
  131. package/dist/organisms/Carousel.d.ts +29 -0
  132. package/dist/organisms/Carousel.js +128 -0
  133. package/dist/organisms/Chart.d.ts +55 -0
  134. package/dist/organisms/Chart.js +189 -0
  135. package/dist/organisms/Combobox.d.ts +41 -0
  136. package/dist/organisms/Combobox.js +102 -0
  137. package/dist/organisms/Command.d.ts +46 -0
  138. package/dist/organisms/Command.js +171 -0
  139. package/dist/organisms/CommandDialog.d.ts +42 -0
  140. package/dist/organisms/CommandDialog.js +116 -0
  141. package/dist/organisms/ContextMenu.d.ts +29 -0
  142. package/dist/organisms/ContextMenu.js +102 -0
  143. package/dist/organisms/DataTable.d.ts +50 -0
  144. package/dist/organisms/DataTable.js +257 -0
  145. package/dist/organisms/DatePicker.d.ts +34 -0
  146. package/dist/organisms/DatePicker.js +115 -0
  147. package/dist/organisms/DateRangePicker.d.ts +34 -0
  148. package/dist/organisms/DateRangePicker.js +122 -0
  149. package/dist/organisms/Dialog.d.ts +50 -0
  150. package/dist/organisms/Dialog.js +116 -0
  151. package/dist/organisms/Drawer.d.ts +40 -0
  152. package/dist/organisms/Drawer.js +131 -0
  153. package/dist/organisms/DropdownMenu.d.ts +30 -0
  154. package/dist/organisms/DropdownMenu.js +91 -0
  155. package/dist/organisms/Form.d.ts +114 -0
  156. package/dist/organisms/Form.js +151 -0
  157. package/dist/organisms/HoverCard.d.ts +30 -0
  158. package/dist/organisms/HoverCard.js +68 -0
  159. package/dist/organisms/Menubar.d.ts +28 -0
  160. package/dist/organisms/Menubar.js +147 -0
  161. package/dist/organisms/MessageScroller.d.ts +36 -0
  162. package/dist/organisms/MessageScroller.js +124 -0
  163. package/dist/organisms/NavigationMenu.d.ts +41 -0
  164. package/dist/organisms/NavigationMenu.js +139 -0
  165. package/dist/organisms/Popover.d.ts +33 -0
  166. package/dist/organisms/Popover.js +58 -0
  167. package/dist/organisms/Questionnaire.d.ts +74 -0
  168. package/dist/organisms/Questionnaire.js +235 -0
  169. package/dist/organisms/Select.d.ts +47 -0
  170. package/dist/organisms/Select.js +250 -0
  171. package/dist/organisms/Sheet.d.ts +34 -0
  172. package/dist/organisms/Sheet.js +107 -0
  173. package/dist/organisms/Sidebar.d.ts +100 -0
  174. package/dist/organisms/Sidebar.js +228 -0
  175. package/dist/organisms/Toaster.d.ts +67 -0
  176. package/dist/organisms/Toaster.js +189 -0
  177. package/dist/organisms/Tooltip.d.ts +39 -0
  178. package/dist/organisms/Tooltip.js +94 -0
  179. package/dist/organisms/index.d.ts +40 -0
  180. package/dist/organisms/index.js +40 -0
  181. package/dist/primitives/controllable.d.ts +38 -0
  182. package/dist/primitives/controllable.js +43 -0
  183. package/dist/primitives/dismissable.d.ts +53 -0
  184. package/dist/primitives/dismissable.js +158 -0
  185. package/dist/primitives/floating.d.ts +116 -0
  186. package/dist/primitives/floating.js +289 -0
  187. package/dist/primitives/floatingSurface.d.ts +56 -0
  188. package/dist/primitives/floatingSurface.js +142 -0
  189. package/dist/primitives/focusTrap.d.ts +47 -0
  190. package/dist/primitives/focusTrap.js +101 -0
  191. package/dist/primitives/focusable.d.ts +53 -0
  192. package/dist/primitives/focusable.js +109 -0
  193. package/dist/primitives/index.d.ts +27 -0
  194. package/dist/primitives/index.js +27 -0
  195. package/dist/primitives/menu.d.ts +102 -0
  196. package/dist/primitives/menu.js +356 -0
  197. package/dist/primitives/modalSurface.d.ts +54 -0
  198. package/dist/primitives/modalSurface.js +146 -0
  199. package/dist/primitives/portal.d.ts +34 -0
  200. package/dist/primitives/portal.js +42 -0
  201. package/dist/primitives/presence.d.ts +45 -0
  202. package/dist/primitives/presence.js +152 -0
  203. package/dist/primitives/rovingFocus.d.ts +47 -0
  204. package/dist/primitives/rovingFocus.js +176 -0
  205. package/dist/primitives/scrollLock.d.ts +21 -0
  206. package/dist/primitives/scrollLock.js +75 -0
  207. package/dist/primitives/typeahead.d.ts +39 -0
  208. package/dist/primitives/typeahead.js +123 -0
  209. package/dist/templates/AppShell.d.ts +30 -0
  210. package/dist/templates/AppShell.js +42 -0
  211. package/dist/templates/AuthLayout.d.ts +23 -0
  212. package/dist/templates/AuthLayout.js +42 -0
  213. package/dist/templates/SettingsLayout.d.ts +27 -0
  214. package/dist/templates/SettingsLayout.js +42 -0
  215. package/dist/templates/index.d.ts +16 -0
  216. package/dist/templates/index.js +16 -0
  217. package/nebula.css +2 -0
  218. package/package.json +92 -0
  219. package/registry.json +1107 -0
  220. package/src/adapters/css.ts +75 -0
  221. package/src/adapters/index.ts +40 -0
  222. package/src/adapters/tailwind.ts +134 -0
  223. package/src/adapters/types.ts +53 -0
  224. package/src/adapters/unocss.ts +116 -0
  225. package/src/atoms/AspectRatio.ts +28 -0
  226. package/src/atoms/Avatar.ts +67 -0
  227. package/src/atoms/Badge.ts +46 -0
  228. package/src/atoms/Button.ts +96 -0
  229. package/src/atoms/Checkbox.ts +76 -0
  230. package/src/atoms/Input.ts +76 -0
  231. package/src/atoms/Kbd.ts +28 -0
  232. package/src/atoms/Label.ts +38 -0
  233. package/src/atoms/Marker.ts +79 -0
  234. package/src/atoms/NativeSelect.ts +115 -0
  235. package/src/atoms/Progress.ts +64 -0
  236. package/src/atoms/ScrollArea.ts +53 -0
  237. package/src/atoms/Separator.ts +36 -0
  238. package/src/atoms/Skeleton.ts +24 -0
  239. package/src/atoms/Slider.ts +65 -0
  240. package/src/atoms/Spinner.ts +28 -0
  241. package/src/atoms/Switch.ts +56 -0
  242. package/src/atoms/Textarea.ts +58 -0
  243. package/src/atoms/Toggle.ts +67 -0
  244. package/src/atoms/index.ts +62 -0
  245. package/src/cli/add.ts +126 -0
  246. package/src/cli/index.ts +22 -0
  247. package/src/cli/registry.ts +116 -0
  248. package/src/config.ts +102 -0
  249. package/src/configure.ts +127 -0
  250. package/src/index.ts +53 -0
  251. package/src/lib/children.ts +43 -0
  252. package/src/lib/cn.ts +14 -0
  253. package/src/lib/cva.ts +249 -0
  254. package/src/lib/icons.ts +138 -0
  255. package/src/lib/id.ts +44 -0
  256. package/src/lib/index.ts +21 -0
  257. package/src/lib/motion.ts +48 -0
  258. package/src/lib/props.ts +83 -0
  259. package/src/lib/styled.ts +60 -0
  260. package/src/molecules/Accordion.ts +161 -0
  261. package/src/molecules/Alert.ts +58 -0
  262. package/src/molecules/Attachment.ts +132 -0
  263. package/src/molecules/Breadcrumb.ts +107 -0
  264. package/src/molecules/Bubble.ts +134 -0
  265. package/src/molecules/ButtonGroup.ts +55 -0
  266. package/src/molecules/Card.ts +42 -0
  267. package/src/molecules/Collapsible.ts +89 -0
  268. package/src/molecules/Empty.ts +43 -0
  269. package/src/molecules/Field.ts +131 -0
  270. package/src/molecules/InputGroup.ts +62 -0
  271. package/src/molecules/InputOTP.ts +166 -0
  272. package/src/molecules/Item.ts +89 -0
  273. package/src/molecules/Message.ts +127 -0
  274. package/src/molecules/Pagination.ts +197 -0
  275. package/src/molecules/RadioGroup.ts +104 -0
  276. package/src/molecules/Resizable.ts +166 -0
  277. package/src/molecules/Table.ts +118 -0
  278. package/src/molecules/Tabs.ts +148 -0
  279. package/src/molecules/ToggleGroup.ts +109 -0
  280. package/src/molecules/Typography.ts +94 -0
  281. package/src/molecules/index.ts +144 -0
  282. package/src/organisms/AlertDialog.ts +133 -0
  283. package/src/organisms/Calendar.ts +470 -0
  284. package/src/organisms/Carousel.ts +161 -0
  285. package/src/organisms/Chart.ts +257 -0
  286. package/src/organisms/Combobox.ts +140 -0
  287. package/src/organisms/Command.ts +211 -0
  288. package/src/organisms/CommandDialog.ts +149 -0
  289. package/src/organisms/ContextMenu.ts +125 -0
  290. package/src/organisms/DataTable.ts +328 -0
  291. package/src/organisms/DatePicker.ts +141 -0
  292. package/src/organisms/DateRangePicker.ts +149 -0
  293. package/src/organisms/Dialog.ts +162 -0
  294. package/src/organisms/Drawer.ts +167 -0
  295. package/src/organisms/DropdownMenu.ts +112 -0
  296. package/src/organisms/Form.ts +217 -0
  297. package/src/organisms/HoverCard.ts +92 -0
  298. package/src/organisms/Menubar.ts +167 -0
  299. package/src/organisms/MessageScroller.ts +145 -0
  300. package/src/organisms/NavigationMenu.ts +184 -0
  301. package/src/organisms/Popover.ts +84 -0
  302. package/src/organisms/Questionnaire.ts +322 -0
  303. package/src/organisms/Select.ts +286 -0
  304. package/src/organisms/Sheet.ts +144 -0
  305. package/src/organisms/Sidebar.ts +336 -0
  306. package/src/organisms/Toaster.ts +246 -0
  307. package/src/organisms/Tooltip.ts +124 -0
  308. package/src/organisms/index.ts +134 -0
  309. package/src/primitives/controllable.ts +64 -0
  310. package/src/primitives/dismissable.ts +195 -0
  311. package/src/primitives/floating.ts +453 -0
  312. package/src/primitives/floatingSurface.ts +209 -0
  313. package/src/primitives/focusTrap.ts +132 -0
  314. package/src/primitives/focusable.ts +108 -0
  315. package/src/primitives/index.ts +88 -0
  316. package/src/primitives/menu.ts +491 -0
  317. package/src/primitives/modalSurface.ts +199 -0
  318. package/src/primitives/portal.ts +61 -0
  319. package/src/primitives/presence.ts +185 -0
  320. package/src/primitives/rovingFocus.ts +212 -0
  321. package/src/primitives/scrollLock.ts +79 -0
  322. package/src/primitives/typeahead.ts +156 -0
  323. package/src/templates/AppShell.ts +56 -0
  324. package/src/templates/AuthLayout.ts +66 -0
  325. package/src/templates/SettingsLayout.ts +63 -0
  326. package/src/templates/index.ts +21 -0
  327. package/theme.css +151 -0
@@ -0,0 +1,145 @@
1
+ /**
2
+ * MessageScroller — a conversation that stays pinned to the newest message.
3
+ *
4
+ * The whole component is one rule and its exception: **follow the bottom, but
5
+ * never yank the reader.**
6
+ *
7
+ * A chat log that always scrolls to the end is unusable the moment you scroll
8
+ * up to re-read something and a new message arrives. One that never scrolls is
9
+ * unusable in the ordinary case. So the container remembers whether the reader
10
+ * was already at the bottom when the content grew: if they were, it follows; if
11
+ * they had scrolled away, it holds position and offers a button instead.
12
+ *
13
+ * Growth is detected with a `ResizeObserver` on the inner wrapper rather than
14
+ * by counting children. An image finishing its load, a code block wrapping, a
15
+ * streamed reply growing token by token — none of those change the child count,
16
+ * and all of them move the bottom.
17
+ *
18
+ * `atBottom` carries a tolerance because a scroll position is fractional on a
19
+ * scaled display: `scrollTop + clientHeight` lands a fraction short of
20
+ * `scrollHeight` at the true bottom, and an exact comparison would decide the
21
+ * reader had scrolled away when they had not.
22
+ *
23
+ * `role="log"` with `aria-live="polite"`: new turns are announced as they
24
+ * arrive, without interrupting whatever is being read.
25
+ */
26
+
27
+ import { component, html, onMount, onUnmount, signal } from "@c9up/aurora";
28
+ import { buttonVariants } from "../atoms/Button.js";
29
+ import { type Slot, slot } from "../lib/children.js";
30
+ import { cn } from "../lib/cn.js";
31
+ import { ChevronDownIcon } from "../lib/icons.js";
32
+ import { uid } from "../lib/id.js";
33
+ import { type Reactive, read } from "../lib/props.js";
34
+
35
+ /** How far from the true bottom still counts as "at the bottom", in px. */
36
+ const BOTTOM_TOLERANCE = 24;
37
+
38
+ export interface MessageScrollerProps {
39
+ children?: Slot;
40
+ /** Announced as the log's name. */
41
+ label?: string;
42
+ /** Label for the catch-up button. Default `"Jump to latest"`. */
43
+ jumpLabel?: string;
44
+ class?: Reactive<string>;
45
+ }
46
+
47
+ export const MessageScroller = component<MessageScrollerProps>((props) => {
48
+ const viewportId = uid("message-scroller");
49
+ const contentId = uid("message-scroller-content");
50
+ const pinned = signal(true);
51
+
52
+ function viewport(): HTMLElement | null {
53
+ return document.getElementById(viewportId);
54
+ }
55
+
56
+ function isAtBottom(element: HTMLElement): boolean {
57
+ const distance =
58
+ element.scrollHeight - element.scrollTop - element.clientHeight;
59
+ return distance <= BOTTOM_TOLERANCE;
60
+ }
61
+
62
+ function scrollToBottom(smooth: boolean): void {
63
+ const element = viewport();
64
+ if (element === null) return;
65
+ element.scrollTo({
66
+ top: element.scrollHeight,
67
+ behavior: smooth ? "smooth" : "auto",
68
+ });
69
+ pinned(true);
70
+ }
71
+
72
+ function onScroll(): void {
73
+ const element = viewport();
74
+ if (element === null) return;
75
+ pinned(isAtBottom(element));
76
+ }
77
+
78
+ /**
79
+ * Follow the bottom only if we were already there.
80
+ *
81
+ * Read from the `pinned` signal rather than re-measuring: by the time the
82
+ * observer fires, the content has already grown and the viewport is no
83
+ * longer at the bottom by definition. The answer has to come from before.
84
+ */
85
+ function onContentResize(): void {
86
+ if (!pinned()) return;
87
+ scrollToBottom(false);
88
+ }
89
+
90
+ let observer: ResizeObserver | undefined;
91
+
92
+ onMount(() => {
93
+ const element = viewport();
94
+ if (element === null) return;
95
+
96
+ element.addEventListener("scroll", onScroll, { passive: true });
97
+ // Start at the newest message, without animating there.
98
+ scrollToBottom(false);
99
+
100
+ if (typeof ResizeObserver !== "function") return;
101
+ const content = document.getElementById(contentId);
102
+ if (content === null) return;
103
+ observer = new ResizeObserver(onContentResize);
104
+ observer.observe(content);
105
+ });
106
+
107
+ onUnmount(() => {
108
+ viewport()?.removeEventListener("scroll", onScroll);
109
+ observer?.disconnect();
110
+ });
111
+
112
+ return html`<div
113
+ data-slot="message-scroller"
114
+ class="${() => cn("relative flex min-h-0 flex-col", read(props.class))}"
115
+ >
116
+ <div
117
+ data-slot="message-scroller-viewport"
118
+ id="${viewportId}"
119
+ role="log"
120
+ aria-live="polite"
121
+ aria-label="${props.label ?? "Conversation"}"
122
+ tabindex="0"
123
+ class="min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none [scrollbar-width:thin]"
124
+ >
125
+ <div
126
+ data-slot="message-scroller-content"
127
+ id="${contentId}"
128
+ class="flex flex-col gap-4 p-4"
129
+ >${slot(props.children)}</div>
130
+ </div>
131
+ <button
132
+ type="button"
133
+ data-slot="message-scroller-jump"
134
+ ?hidden="${() => pinned()}"
135
+ class="${cn(
136
+ buttonVariants({ variant: "secondary", size: "sm" }),
137
+ "absolute inset-x-0 bottom-3 mx-auto w-fit gap-1 rounded-full shadow-md",
138
+ )}"
139
+ @click="${() => scrollToBottom(true)}"
140
+ >
141
+ ${ChevronDownIcon({ class: "size-4" })}
142
+ ${props.jumpLabel ?? "Jump to latest"}
143
+ </button>
144
+ </div>`;
145
+ });
@@ -0,0 +1,184 @@
1
+ /**
2
+ * NavigationMenu — a site navigation bar with drop-down panels.
3
+ *
4
+ * Not a menu, despite the name Radix gave it, and the distinction changes the
5
+ * markup. A menu is a list of *commands*; this is a list of *links*. So it is
6
+ * a `<nav>` containing a list of anchors, with `aria-expanded` on the entries
7
+ * that open a panel — never `role="menu"`, which would have a screen reader
8
+ * announce a navigation bar as an application menu and offer the wrong
9
+ * shortcuts for it.
10
+ *
11
+ * Entries are either a plain link or a link with a panel. Hover opens a panel
12
+ * after a short delay and keeps it open briefly on leave, because the pointer
13
+ * has to travel from the trigger to the panel and a naive `mouseleave` closes
14
+ * it in the gap between them.
15
+ *
16
+ * Every panel is reachable by keyboard: the trigger is a real button, Enter
17
+ * and ArrowDown open it, Escape closes it and returns focus.
18
+ */
19
+
20
+ import { component, html, onUnmount, signal } from "@c9up/aurora";
21
+ import type { Child } from "../lib/children.js";
22
+ import { type Slot, slot } from "../lib/children.js";
23
+ import { cn } from "../lib/cn.js";
24
+ import { ChevronDownIcon } from "../lib/icons.js";
25
+ import { uid } from "../lib/id.js";
26
+ import { zoomInOut } from "../lib/motion.js";
27
+ import { type Reactive, read } from "../lib/props.js";
28
+ import { floatingSurface } from "../primitives/floatingSurface.js";
29
+
30
+ const OPEN_DELAY_MS = 150;
31
+ const CLOSE_DELAY_MS = 250;
32
+
33
+ export interface NavigationLink {
34
+ label: Child;
35
+ href: string;
36
+ description?: Child;
37
+ }
38
+
39
+ export interface NavigationItem {
40
+ label: Child;
41
+ /** A plain link. Mutually exclusive with `links` / `content`. */
42
+ href?: string;
43
+ /** A panel of links, laid out in a grid. */
44
+ links?: readonly NavigationLink[];
45
+ /** Arbitrary panel content, when a link grid is not enough. */
46
+ content?: Slot;
47
+ }
48
+
49
+ export interface NavigationMenuProps {
50
+ items: readonly NavigationItem[];
51
+ class?: Reactive<string>;
52
+ }
53
+
54
+ export const triggerClasses =
55
+ "group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50";
56
+
57
+ export const NavigationMenu = component<NavigationMenuProps>((props) => {
58
+ const contentId = uid("navigation-menu-content");
59
+ const triggerIds = props.items.map(() => uid("navigation-menu-trigger"));
60
+ const openIndex = signal(-1);
61
+
62
+ let timer: ReturnType<typeof setTimeout> | undefined;
63
+
64
+ function clear(): void {
65
+ if (timer !== undefined) clearTimeout(timer);
66
+ timer = undefined;
67
+ }
68
+
69
+ function schedule(index: number, delay: number): void {
70
+ clear();
71
+ timer = setTimeout(() => openIndex(index), delay);
72
+ }
73
+
74
+ onUnmount(clear);
75
+
76
+ function close(): void {
77
+ clear();
78
+ openIndex(-1);
79
+ }
80
+
81
+ floatingSurface({
82
+ anchor: () => {
83
+ const id = triggerIds[openIndex()];
84
+ return id === undefined ? null : document.getElementById(id);
85
+ },
86
+ open: () => openIndex() !== -1,
87
+ onClose: close,
88
+ placement: "bottom-start",
89
+ offset: 6,
90
+ content: () =>
91
+ html`<div
92
+ data-slot="navigation-menu-content"
93
+ id="${contentId}"
94
+ class="${cn(
95
+ "bg-popover text-popover-foreground z-50 rounded-md border p-4 shadow-md outline-none",
96
+ zoomInOut,
97
+ )}"
98
+ @pointerenter="${clear}"
99
+ @pointerleave="${() => schedule(-1, CLOSE_DELAY_MS)}"
100
+ >${renderPanel(props.items[openIndex()])}</div>`,
101
+ });
102
+
103
+ return html`<nav
104
+ data-slot="navigation-menu"
105
+ class="${() => cn("relative flex max-w-max flex-1 items-center justify-center", read(props.class))}"
106
+ >
107
+ <ul class="flex flex-1 list-none items-center justify-center gap-1">
108
+ ${props.items.map((item, index) => renderEntry(item, index))}
109
+ </ul>
110
+ </nav>`;
111
+
112
+ function renderEntry(item: NavigationItem, index: number): Child {
113
+ if (item.href !== undefined) {
114
+ return html`<li>
115
+ <a
116
+ href="${item.href}"
117
+ data-slot="navigation-menu-link"
118
+ class="${triggerClasses}"
119
+ @pointerenter="${() => {
120
+ // Hovering a plain link while a panel is open closes it: the
121
+ // pointer has left the panel's branch of the bar.
122
+ if (openIndex() !== -1) schedule(-1, CLOSE_DELAY_MS);
123
+ }}"
124
+ >${item.label}</a>
125
+ </li>`;
126
+ }
127
+
128
+ return html`<li>
129
+ <button
130
+ type="button"
131
+ id="${triggerIds[index]}"
132
+ data-slot="navigation-menu-trigger"
133
+ aria-expanded="${() => (openIndex() === index ? "true" : "false")}"
134
+ aria-controls="${() => (openIndex() === index ? contentId : undefined)}"
135
+ data-state="${() => (openIndex() === index ? "open" : "closed")}"
136
+ class="${triggerClasses}"
137
+ @click="${() => (openIndex() === index ? close() : openIndex(index))}"
138
+ @pointerenter="${() => schedule(index, OPEN_DELAY_MS)}"
139
+ @pointerleave="${() => schedule(-1, CLOSE_DELAY_MS)}"
140
+ @keydown="${(event: KeyboardEvent) => {
141
+ if (
142
+ event.key === "ArrowDown" ||
143
+ event.key === "Enter" ||
144
+ event.key === " "
145
+ ) {
146
+ event.preventDefault();
147
+ openIndex(index);
148
+ }
149
+ }}"
150
+ >
151
+ ${item.label}
152
+ ${ChevronDownIcon({
153
+ class:
154
+ "relative top-px size-3 transition-transform duration-200 group-data-[state=open]:rotate-180",
155
+ })}
156
+ </button>
157
+ </li>`;
158
+ }
159
+ });
160
+
161
+ function renderPanel(item: NavigationItem | undefined): Child {
162
+ if (item === undefined) return null;
163
+ if (item.content !== undefined) return slot(item.content)();
164
+
165
+ return html`<ul class="grid w-[400px] gap-1 md:w-[500px] md:grid-cols-2">
166
+ ${(item.links ?? []).map(
167
+ (link) => html`<li>
168
+ <a
169
+ href="${link.href}"
170
+ class="hover:bg-accent hover:text-accent-foreground focus:bg-accent block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors"
171
+ >
172
+ <div class="text-sm leading-none font-medium">${link.label}</div>
173
+ ${
174
+ link.description === undefined
175
+ ? null
176
+ : html`<p class="text-muted-foreground line-clamp-2 text-sm leading-snug">
177
+ ${link.description}
178
+ </p>`
179
+ }
180
+ </a>
181
+ </li>`,
182
+ )}
183
+ </ul>`;
184
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Popover — a floating panel anchored to a trigger.
3
+ *
4
+ * The plainest use of `floatingSurface`, and the one to read first: the other
5
+ * anchored overlays in this directory are this component with a different
6
+ * opening gesture and different contents.
7
+ *
8
+ * `modal` decides whether focus is trapped. A popover holding a form should
9
+ * trap — tabbing out of a half-filled form into the page behind loses the
10
+ * user's place. A popover holding a paragraph of help should not, because
11
+ * trapping focus in something the user can only read is a dead end.
12
+ */
13
+
14
+ import { component, html } from "@c9up/aurora";
15
+ import { type Slot, slot } from "../lib/children.js";
16
+ import { cn } from "../lib/cn.js";
17
+ import { uid } from "../lib/id.js";
18
+ import { zoomInOut } from "../lib/motion.js";
19
+ import { type Reactive, read } from "../lib/props.js";
20
+ import { controllable } from "../primitives/controllable.js";
21
+ import type { Placement } from "../primitives/floating.js";
22
+ import { floatingSurface } from "../primitives/floatingSurface.js";
23
+
24
+ export const popoverContentClasses =
25
+ "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none";
26
+
27
+ export interface PopoverProps {
28
+ /** Rendered inside the trigger button. */
29
+ trigger?: Slot;
30
+ /** The panel's contents. */
31
+ children?: Slot;
32
+ open?: Reactive<boolean>;
33
+ defaultOpen?: boolean;
34
+ onOpenChange?: (open: boolean) => void;
35
+ placement?: Placement;
36
+ offset?: number;
37
+ /** Trap focus inside. Use for panels holding form controls. */
38
+ modal?: boolean;
39
+ class?: Reactive<string>;
40
+ triggerClass?: Reactive<string>;
41
+ contentClass?: Reactive<string>;
42
+ }
43
+
44
+ export const Popover = component<PopoverProps>((props) => {
45
+ const triggerId = uid("popover-trigger");
46
+ const contentId = uid("popover-content");
47
+
48
+ const state = controllable<boolean>({
49
+ value: props.open,
50
+ initial: props.defaultOpen ?? false,
51
+ onChange: props.onOpenChange,
52
+ });
53
+
54
+ floatingSurface({
55
+ anchor: () => document.getElementById(triggerId),
56
+ open: () => state.current(),
57
+ onClose: () => state.set(false),
58
+ placement: props.placement ?? "bottom",
59
+ offset: props.offset ?? 4,
60
+ trapFocus: props.modal === true,
61
+ autoFocus: props.modal !== true,
62
+ content: () =>
63
+ html`<div
64
+ data-slot="popover-content"
65
+ id="${contentId}"
66
+ role="dialog"
67
+ aria-labelledby="${triggerId}"
68
+ tabindex="-1"
69
+ class="${cn(popoverContentClasses, zoomInOut, read(props.contentClass))}"
70
+ >${slot(props.children)}</div>`,
71
+ });
72
+
73
+ return html`<button
74
+ type="button"
75
+ data-slot="popover-trigger"
76
+ id="${triggerId}"
77
+ aria-haspopup="dialog"
78
+ aria-expanded="${() => (state.current() ? "true" : "false")}"
79
+ aria-controls="${() => (state.current() ? contentId : undefined)}"
80
+ data-state="${() => (state.current() ? "open" : "closed")}"
81
+ class="${() => cn(read(props.triggerClass))}"
82
+ @click="${() => state.set(!state.current())}"
83
+ >${slot(props.trigger)}</button>`;
84
+ });