@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,144 @@
1
+ /**
2
+ * Sheet — a panel that slides in from an edge.
3
+ *
4
+ * Structurally a Dialog whose panel is pinned to a side rather than centred,
5
+ * so it shares `modalSurface` and everything that comes with it. What differs
6
+ * is the motion: it translates rather than scaling, and the distance depends
7
+ * on the panel's own size, which only `translate-x-full` knows. That is why
8
+ * `slideFrom` produces a transition and not a keyframe animation.
9
+ *
10
+ * `inset-y-0` for the side panels and `inset-x-0` for the top and bottom ones
11
+ * is what makes the sheet span its edge; the opposite axis takes the width or
12
+ * height. Getting that pair the wrong way round produces a panel floating in a
13
+ * corner, which is the usual first attempt.
14
+ */
15
+
16
+ import { component, html } from "@c9up/aurora";
17
+ import type { Child } from "../lib/children.js";
18
+ import { type Slot, slot } from "../lib/children.js";
19
+ import { cn } from "../lib/cn.js";
20
+ import { XIcon } from "../lib/icons.js";
21
+ import { uid } from "../lib/id.js";
22
+ import { fadeInOut, slideFrom } from "../lib/motion.js";
23
+ import { type Reactive, read } from "../lib/props.js";
24
+ import { controllable } from "../primitives/controllable.js";
25
+ import type { Side } from "../primitives/floating.js";
26
+ import { modalSurface } from "../primitives/modalSurface.js";
27
+ import { dialogBackdropClasses } from "./Dialog.js";
28
+
29
+ export interface SheetProps {
30
+ trigger?: Slot;
31
+ title: Child;
32
+ description?: Child;
33
+ children?: Slot;
34
+ footer?: Slot;
35
+ /** Which edge it comes from. Default `"right"`. */
36
+ side?: Side;
37
+ open?: Reactive<boolean>;
38
+ defaultOpen?: boolean;
39
+ onOpenChange?: (open: boolean) => void;
40
+ srOnlyTitle?: boolean;
41
+ triggerClass?: Reactive<string>;
42
+ contentClass?: Reactive<string>;
43
+ }
44
+
45
+ /** Edge placement and the axis the panel is sized on. */
46
+ function panelClassesFor(side: Side): string {
47
+ if (side === "left") {
48
+ return "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm";
49
+ }
50
+ if (side === "right") {
51
+ return "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm";
52
+ }
53
+ if (side === "top") return "inset-x-0 top-0 h-auto border-b";
54
+ return "inset-x-0 bottom-0 h-auto border-t";
55
+ }
56
+
57
+ export const Sheet = component<SheetProps>((props) => {
58
+ const side = props.side ?? "right";
59
+ const triggerId = uid("sheet-trigger");
60
+ const panelId = uid("sheet-panel");
61
+ const titleId = uid("sheet-title");
62
+ const descriptionId = uid("sheet-description");
63
+
64
+ const state = controllable<boolean>({
65
+ value: props.open,
66
+ initial: props.defaultOpen ?? false,
67
+ onChange: props.onOpenChange,
68
+ });
69
+
70
+ modalSurface({
71
+ open: () => state.current(),
72
+ onClose: () => state.set(false),
73
+ panel: (root) => root.querySelector(`#${CSS.escape(panelId)}`),
74
+ returnFocus: () => document.getElementById(triggerId),
75
+ content: () =>
76
+ html`<div data-slot="sheet-overlay" class="${cn("fixed inset-0 z-50", fadeInOut)}">
77
+ <div class="${dialogBackdropClasses}"></div>
78
+ <div
79
+ data-slot="sheet-content"
80
+ data-side="${side}"
81
+ id="${panelId}"
82
+ role="dialog"
83
+ aria-modal="true"
84
+ aria-labelledby="${titleId}"
85
+ aria-describedby="${props.description === undefined ? undefined : descriptionId}"
86
+ tabindex="-1"
87
+ class="${cn(
88
+ "bg-background fixed z-50 flex flex-col gap-4 p-6 shadow-lg outline-none",
89
+ panelClassesFor(side),
90
+ slideFrom(side),
91
+ read(props.contentClass),
92
+ )}"
93
+ >
94
+ <div data-slot="sheet-header" class="flex flex-col gap-1.5">
95
+ <h2
96
+ id="${titleId}"
97
+ class="${cn(
98
+ "text-foreground font-semibold",
99
+ props.srOnlyTitle === true ? "sr-only" : "",
100
+ )}"
101
+ >${props.title}</h2>
102
+ ${
103
+ props.description === undefined
104
+ ? null
105
+ : html`<p id="${descriptionId}" class="text-muted-foreground text-sm">
106
+ ${props.description}
107
+ </p>`
108
+ }
109
+ </div>
110
+ <div data-slot="sheet-body" class="flex-1 overflow-y-auto">${slot(props.children)}</div>
111
+ ${
112
+ props.footer === undefined
113
+ ? null
114
+ : html`<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
115
+ ${slot(props.footer)}
116
+ </div>`
117
+ }
118
+ <button
119
+ type="button"
120
+ data-slot="sheet-close"
121
+ class="ring-offset-background focus-visible:ring-ring absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus-visible:ring-2 focus-visible:outline-none"
122
+ @click="${() => state.set(false)}"
123
+ >
124
+ ${XIcon({ class: "size-4" })}
125
+ <span class="sr-only">Close</span>
126
+ </button>
127
+ </div>
128
+ </div>`,
129
+ });
130
+
131
+ if (props.trigger === undefined) {
132
+ return html`<span data-slot="sheet" hidden></span>`;
133
+ }
134
+
135
+ return html`<button
136
+ type="button"
137
+ data-slot="sheet-trigger"
138
+ id="${triggerId}"
139
+ aria-haspopup="dialog"
140
+ aria-expanded="${() => (state.current() ? "true" : "false")}"
141
+ class="${() => cn(read(props.triggerClass))}"
142
+ @click="${() => state.set(true)}"
143
+ >${slot(props.trigger)}</button>`;
144
+ });
@@ -0,0 +1,336 @@
1
+ /**
2
+ * Sidebar — the application shell's navigation column.
3
+ *
4
+ * Two presentations from one definition, because a sidebar is not a component
5
+ * so much as a layout decision that changes with the viewport:
6
+ *
7
+ * - On a wide screen it is a column beside the content, collapsible to a strip
8
+ * of icons or away entirely.
9
+ * - On a narrow one it is a Sheet. A 16rem column on a phone leaves nothing
10
+ * for the content, and every attempt to keep it visible ends up as a drawer
11
+ * anyway.
12
+ *
13
+ * The collapsed state is persisted in a cookie rather than `localStorage`. The
14
+ * server renders the shell, and only a cookie is readable there — with
15
+ * `localStorage` the sidebar renders expanded, then snaps shut once the client
16
+ * hydrates, which is visible on every page load.
17
+ *
18
+ * `cmd/ctrl + B` toggles it, the convention from every editor that has one.
19
+ */
20
+
21
+ import {
22
+ booleanCookie,
23
+ component,
24
+ cookieState,
25
+ html,
26
+ onMount,
27
+ onUnmount,
28
+ type Signal,
29
+ signal,
30
+ } from "@c9up/aurora";
31
+ import { Button } from "../atoms/Button.js";
32
+ import type { Child, Slot } from "../lib/children.js";
33
+ import { slot } from "../lib/children.js";
34
+ import { cn } from "../lib/cn.js";
35
+ import { PanelLeftIcon } from "../lib/icons.js";
36
+ import { type Reactive, read } from "../lib/props.js";
37
+ import { type StyledProps, styledDiv } from "../lib/styled.js";
38
+ import { Sheet } from "./Sheet.js";
39
+ import { Tooltip } from "./Tooltip.js";
40
+
41
+ const COOKIE_NAME = "nebula:sidebar";
42
+
43
+ /**
44
+ * The open/collapsed state, shared by every part of the sidebar.
45
+ *
46
+ * shadcn publishes this through a `SidebarProvider` and React context, so that
47
+ * a menu item three levels down can know the sidebar is collapsed and show a
48
+ * tooltip instead of a label. Aurora has no context, and the honest
49
+ * replacement is a module-level signal: an app has one sidebar, so one signal
50
+ * is the whole of what the provider was carrying.
51
+ *
52
+ * Created on first use rather than at import, because `cookieState` reads the
53
+ * cookie immediately and this module is imported during SSR too.
54
+ */
55
+ let sharedOpen: Signal<boolean> | undefined;
56
+
57
+ export function sidebarState(defaultOpen = true): Signal<boolean> {
58
+ sharedOpen ??= cookieState(COOKIE_NAME, defaultOpen, booleanCookie, {
59
+ path: "/",
60
+ maxAge: COOKIE_MAX_AGE_DAYS * 24 * 60 * 60,
61
+ sameSite: "lax",
62
+ });
63
+ return sharedOpen;
64
+ }
65
+
66
+ /** Is the sidebar collapsed to its icon rail right now? */
67
+ export function sidebarCollapsed(): boolean {
68
+ return !sidebarState()();
69
+ }
70
+ const COOKIE_MAX_AGE_DAYS = 365;
71
+ /** Below this width the sidebar becomes a Sheet. Matches Tailwind's `md`. */
72
+ const MOBILE_BREAKPOINT = 768;
73
+
74
+ export interface SidebarProps {
75
+ children?: Slot;
76
+ header?: Slot;
77
+ footer?: Slot;
78
+ /** Which edge. Default `"left"`. */
79
+ side?: "left" | "right";
80
+ /** What collapsing does: shrink to icons, or disappear. Default `"icon"`. */
81
+ collapsible?: "icon" | "offcanvas";
82
+ /** Starting state. Read from the cookie when there is one. */
83
+ defaultOpen?: boolean;
84
+ /** Announced as the navigation landmark's name. */
85
+ label?: string;
86
+ class?: Reactive<string>;
87
+ }
88
+
89
+ export const Sidebar = component<SidebarProps>((props) => {
90
+ const side = props.side ?? "left";
91
+ const collapsible = props.collapsible ?? "icon";
92
+ // The shared signal, so the menu items collapse in step with the panel.
93
+ const open = sidebarState(props.defaultOpen ?? true);
94
+ const mobile = signal(false);
95
+
96
+ function toggle(): void {
97
+ open(!open());
98
+ }
99
+
100
+ function onKeyDown(event: KeyboardEvent): void {
101
+ if (event.key !== "b" && event.key !== "B") return;
102
+ if (!event.metaKey && !event.ctrlKey) return;
103
+ event.preventDefault();
104
+ toggle();
105
+ }
106
+
107
+ // `matchMedia` rather than a resize listener: it fires only when the
108
+ // breakpoint is actually crossed, not on every pixel of a window drag.
109
+ let media: MediaQueryList | undefined;
110
+ function syncMobile(): void {
111
+ mobile(media?.matches === true);
112
+ }
113
+
114
+ onMount(() => {
115
+ media = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
116
+ syncMobile();
117
+ media.addEventListener("change", syncMobile);
118
+ document.addEventListener("keydown", onKeyDown);
119
+ });
120
+
121
+ onUnmount(() => {
122
+ media?.removeEventListener("change", syncMobile);
123
+ document.removeEventListener("keydown", onKeyDown);
124
+ });
125
+
126
+ const body = (): Child =>
127
+ html`<div data-slot="sidebar-inner" class="flex h-full w-full flex-col">
128
+ ${
129
+ props.header === undefined
130
+ ? null
131
+ : html`<div data-slot="sidebar-header" class="flex flex-col gap-2 p-2">
132
+ ${slot(props.header)}
133
+ </div>`
134
+ }
135
+ <div data-slot="sidebar-body" class="min-h-0 flex-1 overflow-auto p-2">
136
+ ${slot(props.children)}
137
+ </div>
138
+ ${
139
+ props.footer === undefined
140
+ ? null
141
+ : html`<div data-slot="sidebar-footer" class="flex flex-col gap-2 p-2">
142
+ ${slot(props.footer)}
143
+ </div>`
144
+ }
145
+ </div>`;
146
+
147
+ return html`<div data-slot="sidebar-root">
148
+ ${() =>
149
+ mobile()
150
+ ? Sheet({
151
+ title: props.label ?? "Navigation",
152
+ srOnlyTitle: true,
153
+ side,
154
+ open: () => open(),
155
+ onOpenChange: (next) => open(next),
156
+ children: body(),
157
+ contentClass: "bg-sidebar text-sidebar-foreground w-[18rem] p-0",
158
+ })
159
+ : html`<nav
160
+ data-slot="sidebar"
161
+ data-state="${() => (open() ? "expanded" : "collapsed")}"
162
+ data-side="${side}"
163
+ data-collapsible="${collapsible}"
164
+ aria-label="${props.label ?? "Sidebar"}"
165
+ class="${() =>
166
+ cn(
167
+ "bg-sidebar text-sidebar-foreground h-svh shrink-0 overflow-hidden transition-[width] duration-200 ease-linear motion-reduce:transition-none",
168
+ side === "left" ? "border-r" : "border-l",
169
+ open()
170
+ ? "w-(--sidebar-width,16rem)"
171
+ : collapsible === "icon"
172
+ ? "w-(--sidebar-width-icon,3rem)"
173
+ : "w-0 border-0",
174
+ read(props.class),
175
+ )}"
176
+ >${body()}</nav>`}
177
+ </div>`;
178
+ });
179
+
180
+ export interface SidebarTriggerProps {
181
+ onToggle: () => void;
182
+ class?: Reactive<string>;
183
+ }
184
+
185
+ /**
186
+ * The button that opens and closes the sidebar.
187
+ *
188
+ * Kept apart from `Sidebar` because it belongs in the page header, not in the
189
+ * panel — a trigger inside an off-canvas sidebar disappears with it, and there
190
+ * is then no way back.
191
+ */
192
+ export const SidebarTrigger = component<SidebarTriggerProps>((props) => {
193
+ return Button({
194
+ variant: "ghost",
195
+ size: "icon",
196
+ label: "Toggle sidebar",
197
+ class: props.class,
198
+ onClick: props.onToggle,
199
+ children: PanelLeftIcon({ class: "size-4" }),
200
+ });
201
+ });
202
+
203
+ export const SidebarGroup = styledDiv(
204
+ "sidebar-group",
205
+ "flex w-full flex-col gap-1 p-2",
206
+ );
207
+
208
+ export const SidebarGroupLabel = styledDiv(
209
+ "sidebar-group-label",
210
+ "text-sidebar-foreground/70 flex h-8 shrink-0 items-center rounded-md px-2 text-xs font-medium",
211
+ );
212
+
213
+ export const SidebarMenu = styledDiv(
214
+ "sidebar-menu",
215
+ "flex w-full min-w-0 flex-col gap-1",
216
+ );
217
+
218
+ export interface SidebarMenuItemProps {
219
+ label: Child;
220
+ href?: string;
221
+ icon?: Child;
222
+ active?: Reactive<boolean>;
223
+ /**
224
+ * Shown as a tooltip while the sidebar is collapsed to icons.
225
+ *
226
+ * Only then: an entry whose label is already on screen does not need the
227
+ * same words repeated on hover. Without it a collapsed rail is a column of
228
+ * unlabelled glyphs, so give one to every entry that has an icon.
229
+ */
230
+ tooltip?: string;
231
+ /** A trailing control — the count of unread items, a status dot. */
232
+ badge?: Child;
233
+ onClick?: () => void;
234
+ }
235
+
236
+ /**
237
+ * One navigation entry.
238
+ *
239
+ * `aria-current="page"` on the active entry, not just a highlight class. The
240
+ * highlight tells a sighted user where they are; without the attribute nobody
241
+ * else is told at all.
242
+ */
243
+ export const SidebarMenuItem = component<SidebarMenuItemProps>((props) => {
244
+ const classes =
245
+ "hover:bg-sidebar-accent hover:text-sidebar-accent-foreground aria-[current=page]:bg-sidebar-accent aria-[current=page]:font-medium flex h-8 w-full items-center gap-2 overflow-hidden rounded-md px-2 text-left text-sm outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0";
246
+
247
+ /**
248
+ * The label and badge fold away with the rail, the icon does not.
249
+ *
250
+ * `sr-only` rather than `hidden`: the entry keeps its accessible name when
251
+ * collapsed, so a screen reader still reads "Settings" off a column that
252
+ * shows only a cog.
253
+ */
254
+ const foldable = (): string => (sidebarCollapsed() ? "sr-only" : "truncate");
255
+
256
+ const body = html`${props.icon}<span class="${foldable}">${props.label}</span>${() =>
257
+ props.badge === undefined || sidebarCollapsed()
258
+ ? null
259
+ : html`<span
260
+ data-slot="sidebar-menu-badge"
261
+ class="text-sidebar-foreground/70 ml-auto flex h-5 min-w-5 shrink-0 items-center justify-center rounded-md px-1 text-xs font-medium tabular-nums"
262
+ >${props.badge}</span>`}`;
263
+
264
+ const entry =
265
+ props.href !== undefined
266
+ ? html`<a
267
+ data-slot="sidebar-menu-item"
268
+ href="${props.href}"
269
+ aria-current="${() => (read(props.active) === true ? "page" : undefined)}"
270
+ class="${classes}"
271
+ >${body}</a>`
272
+ : html`<button
273
+ type="button"
274
+ data-slot="sidebar-menu-item"
275
+ aria-current="${() => (read(props.active) === true ? "page" : undefined)}"
276
+ class="${classes}"
277
+ @click="${props.onClick}"
278
+ >${body}</button>`;
279
+
280
+ if (props.tooltip === undefined) return entry;
281
+
282
+ // Wrapped once, not toggled: swapping the wrapper on collapse would remount
283
+ // the entry and lose focus mid-keyboard-navigation. The tooltip decides for
284
+ // itself whether to open, which is the cheaper half to make conditional.
285
+ return html`${() =>
286
+ sidebarCollapsed()
287
+ ? Tooltip({ trigger: entry, content: props.tooltip, placement: "right" })
288
+ : entry}`;
289
+ });
290
+
291
+ /**
292
+ * A second control on a menu row — a "more" menu, a remove button.
293
+ *
294
+ * Absolutely positioned rather than a flex sibling, so it can overlap a long
295
+ * label instead of squeezing it. It disappears with the rail: there is no room
296
+ * for two controls in an icon-width column, and the row's own action is the
297
+ * one that matters.
298
+ */
299
+ export const SidebarMenuAction = component<SidebarMenuItemProps>((props) => {
300
+ return html`<button
301
+ type="button"
302
+ data-slot="sidebar-menu-action"
303
+ aria-label="${props.tooltip}"
304
+ class="${() =>
305
+ cn(
306
+ "text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground absolute top-1 right-1 flex aspect-square w-5 items-center justify-center rounded-md outline-none focus-visible:ring-2 focus-visible:ring-sidebar-ring [&>svg]:size-4 [&>svg]:shrink-0",
307
+ sidebarCollapsed() ? "hidden" : "",
308
+ )}"
309
+ @click="${props.onClick}"
310
+ >${props.icon ?? props.label}</button>`;
311
+ });
312
+
313
+ /**
314
+ * A nested list under a menu entry.
315
+ *
316
+ * A real `<ul>`, so the nesting is structure a screen reader can announce
317
+ * rather than an indent. Hidden entirely when collapsed — an indented tree in
318
+ * an icon-width rail is unreadable, and the parent entry is still reachable.
319
+ */
320
+ export const SidebarMenuSub = component<StyledProps>((props) => {
321
+ return html`<ul
322
+ data-slot="sidebar-menu-sub"
323
+ class="${() =>
324
+ cn(
325
+ "border-sidebar-border mx-3.5 flex min-w-0 translate-x-px flex-col gap-1 border-l px-2.5 py-0.5",
326
+ sidebarCollapsed() ? "hidden" : "",
327
+ read(props.class),
328
+ )}"
329
+ >${slot(props.children)}</ul>`;
330
+ });
331
+
332
+ /** One row of a nested list. Wrap a `SidebarMenuItem` in it. */
333
+ export const SidebarMenuSubItem = styledDiv(
334
+ "sidebar-menu-sub-item",
335
+ "relative flex min-w-0 items-center",
336
+ );