@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,257 @@
1
+ /**
2
+ * Chart — line, area and bar charts drawn as inline SVG.
3
+ *
4
+ * shadcn's Chart is a theming wrapper around Recharts. nebula cannot follow it
5
+ * there — Recharts is React — and a zero-dependency port of a full charting
6
+ * library is not a thing to promise. So the scope is stated plainly: three
7
+ * chart types over one categorical axis and one numeric axis, which is what
8
+ * the shadcn chart examples are and what dashboards overwhelmingly need.
9
+ *
10
+ * Anything beyond that — stacked bars with negative values, dual axes, brushes,
11
+ * candlesticks — is a charting library's job, and an app needing one should
12
+ * install one rather than have this component grow into a bad copy.
13
+ *
14
+ * The colours come from `--chart-1` … `--chart-5`, so a chart re-themes with
15
+ * the rest of the design system instead of carrying its own palette.
16
+ *
17
+ * An SVG chart is invisible to a screen reader whatever ARIA is put on it, so
18
+ * the same data is emitted as a real `<table>`, visually hidden. That is the
19
+ * only approach that actually works: the reader gets rows and values it can
20
+ * navigate, not a described picture.
21
+ */
22
+
23
+ import { component, html } from "@c9up/aurora";
24
+ import { cn } from "../lib/cn.js";
25
+ import { uid } from "../lib/id.js";
26
+ import { type Reactive, read } from "../lib/props.js";
27
+
28
+ export interface ChartSeries {
29
+ key: string;
30
+ label: string;
31
+ /** Any CSS colour. Defaults to `--chart-1`…`--chart-5` by position. */
32
+ color?: string;
33
+ }
34
+
35
+ /** One row: a category plus one numeric value per series key. */
36
+ export type ChartDatum = Record<string, string | number>;
37
+
38
+ export interface ChartProps {
39
+ data: readonly ChartDatum[];
40
+ series: readonly ChartSeries[];
41
+ /** Key in each datum holding the category label. */
42
+ categoryKey: string;
43
+ type?: "line" | "area" | "bar";
44
+ height?: number;
45
+ /** Announced as the chart's name, and used as the data table's caption. */
46
+ label: string;
47
+ /** Draw horizontal gridlines. Default `true`. */
48
+ grid?: boolean;
49
+ class?: Reactive<string>;
50
+ }
51
+
52
+ const VIEWBOX_WIDTH = 600;
53
+ const PADDING = { top: 8, right: 8, bottom: 24, left: 40 };
54
+
55
+ function colorFor(series: ChartSeries, index: number): string {
56
+ return series.color ?? `var(--chart-${(index % 5) + 1})`;
57
+ }
58
+
59
+ function numberAt(datum: ChartDatum, key: string): number {
60
+ const value = datum[key];
61
+ return typeof value === "number" ? value : 0;
62
+ }
63
+
64
+ function categoryAt(datum: ChartDatum, key: string): string {
65
+ const value = datum[key];
66
+ return typeof value === "string" ? value : String(value ?? "");
67
+ }
68
+
69
+ /**
70
+ * The value range the Y axis covers.
71
+ *
72
+ * Anchored at zero whenever the data is non-negative. A bar chart whose axis
73
+ * starts at the minimum value exaggerates every difference — the classic
74
+ * misleading chart — and it is not a choice worth exposing as an option.
75
+ */
76
+ export function valueRange(
77
+ data: readonly ChartDatum[],
78
+ series: readonly ChartSeries[],
79
+ ): { min: number; max: number } {
80
+ let min = 0;
81
+ let max = 0;
82
+ for (const datum of data) {
83
+ for (const entry of series) {
84
+ const value = numberAt(datum, entry.key);
85
+ if (value < min) min = value;
86
+ if (value > max) max = value;
87
+ }
88
+ }
89
+ // A flat all-zero dataset would divide by zero when scaling.
90
+ if (min === max) return { min, max: max + 1 };
91
+ return { min, max };
92
+ }
93
+
94
+ export const Chart = component<ChartProps>((props) => {
95
+ const titleId = uid("chart-title");
96
+ const height = props.height ?? 240;
97
+ const type = props.type ?? "line";
98
+ const { min, max } = valueRange(props.data, props.series);
99
+
100
+ const plotWidth = VIEWBOX_WIDTH - PADDING.left - PADDING.right;
101
+ const plotHeight = height - PADDING.top - PADDING.bottom;
102
+
103
+ const y = (value: number): number =>
104
+ PADDING.top + plotHeight - ((value - min) / (max - min)) * plotHeight;
105
+
106
+ /** Centre of the slot for index `i`, so points sit above their labels. */
107
+ const x = (index: number): number => {
108
+ const count = Math.max(props.data.length, 1);
109
+ const step = plotWidth / count;
110
+ return PADDING.left + step * index + step / 2;
111
+ };
112
+
113
+ const gridLines = [0, 0.25, 0.5, 0.75, 1].map(
114
+ (fraction) => PADDING.top + plotHeight * fraction,
115
+ );
116
+
117
+ function linePath(series: ChartSeries): string {
118
+ return props.data
119
+ .map(
120
+ (datum, index) =>
121
+ `${index === 0 ? "M" : "L"}${x(index)},${y(numberAt(datum, series.key))}`,
122
+ )
123
+ .join(" ");
124
+ }
125
+
126
+ function areaPath(series: ChartSeries): string {
127
+ if (props.data.length === 0) return "";
128
+ const baseline = y(Math.max(min, 0));
129
+ return `${linePath(series)} L${x(props.data.length - 1)},${baseline} L${x(0)},${baseline} Z`;
130
+ }
131
+
132
+ return html`<figure
133
+ data-slot="chart"
134
+ class="${() => cn("w-full", read(props.class))}"
135
+ >
136
+ <svg
137
+ viewBox="${`0 0 ${VIEWBOX_WIDTH} ${height}`}"
138
+ role="img"
139
+ aria-labelledby="${titleId}"
140
+ preserveAspectRatio="none"
141
+ class="h-auto w-full overflow-visible"
142
+ >
143
+ <title id="${titleId}">${props.label}</title>
144
+ ${
145
+ props.grid === false
146
+ ? null
147
+ : gridLines.map(
148
+ (position) => html`<line
149
+ x1="${PADDING.left}"
150
+ x2="${VIEWBOX_WIDTH - PADDING.right}"
151
+ y1="${position}"
152
+ y2="${position}"
153
+ stroke="var(--border)"
154
+ stroke-width="1"
155
+ />`,
156
+ )
157
+ }
158
+ ${props.series.map((series, seriesIndex) =>
159
+ type === "bar"
160
+ ? renderBars(series, seriesIndex)
161
+ : renderCurve(series, seriesIndex),
162
+ )}
163
+ ${props.data.map(
164
+ (datum, index) => html`<text
165
+ x="${x(index)}"
166
+ y="${height - 6}"
167
+ text-anchor="middle"
168
+ font-size="10"
169
+ fill="var(--muted-foreground)"
170
+ >${categoryAt(datum, props.categoryKey)}</text>`,
171
+ )}
172
+ </svg>
173
+ <figcaption class="sr-only">${renderDataTable()}</figcaption>
174
+ <div data-slot="chart-legend" class="mt-2 flex flex-wrap justify-center gap-4 text-xs">
175
+ ${props.series.map(
176
+ (series, index) => html`<span class="flex items-center gap-1.5">
177
+ <span
178
+ aria-hidden="true"
179
+ class="size-2 shrink-0 rounded-[2px]"
180
+ style="${`background:${colorFor(series, index)}`}"
181
+ ></span>
182
+ <span class="text-muted-foreground">${series.label}</span>
183
+ </span>`,
184
+ )}
185
+ </div>
186
+ </figure>`;
187
+
188
+ function renderCurve(
189
+ series: ChartSeries,
190
+ index: number,
191
+ ): ReturnType<typeof html> {
192
+ const color = colorFor(series, index);
193
+ return html`<g>
194
+ ${
195
+ type === "area"
196
+ ? html`<path d="${areaPath(series)}" fill="${color}" fill-opacity="0.15" />`
197
+ : null
198
+ }
199
+ <path
200
+ d="${linePath(series)}"
201
+ fill="none"
202
+ stroke="${color}"
203
+ stroke-width="2"
204
+ stroke-linecap="round"
205
+ stroke-linejoin="round"
206
+ />
207
+ </g>`;
208
+ }
209
+
210
+ function renderBars(
211
+ series: ChartSeries,
212
+ seriesIndex: number,
213
+ ): ReturnType<typeof html> {
214
+ const count = Math.max(props.data.length, 1);
215
+ const slot = plotWidth / count;
216
+ // Share each slot between the series so grouped bars sit side by side,
217
+ // with a quarter of the slot left as the gap between groups.
218
+ const barWidth = (slot * 0.75) / props.series.length;
219
+ const baseline = y(Math.max(min, 0));
220
+
221
+ return html`<g fill="${colorFor(series, seriesIndex)}">
222
+ ${props.data.map((datum, index) => {
223
+ const top = y(numberAt(datum, series.key));
224
+ const left =
225
+ PADDING.left + slot * index + slot * 0.125 + barWidth * seriesIndex;
226
+ return html`<rect
227
+ x="${left}"
228
+ y="${Math.min(top, baseline)}"
229
+ width="${barWidth}"
230
+ height="${Math.abs(baseline - top)}"
231
+ rx="2"
232
+ />`;
233
+ })}
234
+ </g>`;
235
+ }
236
+
237
+ /** The same numbers as a real table, for anything that cannot see the SVG. */
238
+ function renderDataTable(): ReturnType<typeof html> {
239
+ return html`<table>
240
+ <caption>${props.label}</caption>
241
+ <thead>
242
+ <tr>
243
+ <th scope="col">${props.categoryKey}</th>
244
+ ${props.series.map((series) => html`<th scope="col">${series.label}</th>`)}
245
+ </tr>
246
+ </thead>
247
+ <tbody>
248
+ ${props.data.map(
249
+ (datum) => html`<tr>
250
+ <th scope="row">${categoryAt(datum, props.categoryKey)}</th>
251
+ ${props.series.map((series) => html`<td>${numberAt(datum, series.key)}</td>`)}
252
+ </tr>`,
253
+ )}
254
+ </tbody>
255
+ </table>`;
256
+ }
257
+ });
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Combobox — a select you can type into.
3
+ *
4
+ * Structurally a Select whose panel holds a search field, which is exactly how
5
+ * shadcn builds it (a Popover wrapping a Command). nebula does the same, so
6
+ * the filtering, the highlight and the keyboard model are `Command`'s and are
7
+ * not written twice.
8
+ *
9
+ * The one addition over Command is selection: a combobox remembers what was
10
+ * picked, shows it on the trigger, and posts it with the form. Command runs an
11
+ * action and forgets.
12
+ *
13
+ * Focus moves into the panel's search field on open — that is the point of the
14
+ * control — and returns to the trigger on close, so the tab order does not
15
+ * jump to the top of the page after choosing.
16
+ */
17
+
18
+ import { component, html, signal } from "@c9up/aurora";
19
+ import { cn } from "../lib/cn.js";
20
+ import { ChevronsUpDownIcon } from "../lib/icons.js";
21
+ import { uid } from "../lib/id.js";
22
+ import { zoomInOut } from "../lib/motion.js";
23
+ import { type Reactive, read, readOr } from "../lib/props.js";
24
+ import { controllable } from "../primitives/controllable.js";
25
+ import { floatingSurface } from "../primitives/floatingSurface.js";
26
+ import { focusSilently } from "../primitives/focusable.js";
27
+ import { Command, type CommandItem } from "./Command.js";
28
+ import { selectTriggerClasses } from "./Select.js";
29
+
30
+ export interface ComboboxOption {
31
+ value: string;
32
+ label: string;
33
+ keywords?: readonly string[];
34
+ disabled?: boolean;
35
+ group?: string;
36
+ }
37
+
38
+ export interface ComboboxProps {
39
+ options: readonly ComboboxOption[];
40
+ name?: string;
41
+ value?: Reactive<string | undefined>;
42
+ defaultValue?: string;
43
+ /** Trigger text when nothing is chosen. */
44
+ placeholder?: string;
45
+ /** Placeholder inside the search field. */
46
+ searchPlaceholder?: string;
47
+ emptyMessage?: string;
48
+ disabled?: Reactive<boolean>;
49
+ invalid?: Reactive<boolean>;
50
+ class?: Reactive<string>;
51
+ contentClass?: Reactive<string>;
52
+ onValueChange?: (value: string) => void;
53
+ }
54
+
55
+ export const Combobox = component<ComboboxProps>((props) => {
56
+ const triggerId = uid("combobox-trigger");
57
+ const contentId = uid("combobox-content");
58
+ const open = signal(false);
59
+
60
+ const selection = controllable<string | undefined>({
61
+ value: props.value,
62
+ initial: props.defaultValue,
63
+ onChange: (next) => {
64
+ if (next !== undefined) props.onValueChange?.(next);
65
+ },
66
+ });
67
+
68
+ function labelFor(value: string | undefined): string | undefined {
69
+ return props.options.find((option) => option.value === value)?.label;
70
+ }
71
+
72
+ const items = (): readonly CommandItem[] =>
73
+ props.options.map((option) => ({
74
+ value: option.value,
75
+ label: option.label,
76
+ keywords: option.keywords,
77
+ disabled: option.disabled,
78
+ group: option.group,
79
+ }));
80
+
81
+ function choose(item: CommandItem): void {
82
+ selection.set(item.value);
83
+ open(false);
84
+ focusSilently(document.getElementById(triggerId));
85
+ }
86
+
87
+ floatingSurface({
88
+ anchor: () => document.getElementById(triggerId),
89
+ open: () => open(),
90
+ onClose: () => open(false),
91
+ placement: "bottom-start",
92
+ offset: 4,
93
+ matchWidth: true,
94
+ trapFocus: true,
95
+ // Focus the search field, not the first result — the user opened this to
96
+ // type. `firstFocusable` would land on whichever element comes first in
97
+ // the DOM, which is only the input by accident.
98
+ initialFocus: (content) => content.querySelector("input"),
99
+ content: () =>
100
+ html`<div
101
+ data-slot="combobox-content"
102
+ id="${contentId}"
103
+ class="${cn(
104
+ "bg-popover text-popover-foreground z-50 min-w-(--nebula-anchor-width) overflow-hidden rounded-md border shadow-md",
105
+ zoomInOut,
106
+ read(props.contentClass),
107
+ )}"
108
+ >
109
+ ${Command({
110
+ items: items(),
111
+ placeholder: props.searchPlaceholder ?? "Search…",
112
+ emptyMessage: props.emptyMessage ?? "No results found.",
113
+ onSelect: choose,
114
+ })}
115
+ </div>`,
116
+ });
117
+
118
+ return html`<div data-slot="combobox" class="${() => cn("inline-flex", read(props.class))}">
119
+ <button
120
+ type="button"
121
+ data-slot="combobox-trigger"
122
+ id="${triggerId}"
123
+ role="combobox"
124
+ aria-haspopup="listbox"
125
+ aria-expanded="${() => (open() ? "true" : "false")}"
126
+ aria-controls="${() => (open() ? contentId : undefined)}"
127
+ aria-invalid="${() => (read(props.invalid) === true ? "true" : undefined)}"
128
+ data-placeholder="${() => (selection.current() === undefined ? "" : undefined)}"
129
+ ?disabled="${() => readOr(props.disabled, false)}"
130
+ class="${() => cn(selectTriggerClasses, "justify-between")}"
131
+ @click="${() => open(!open())}"
132
+ >
133
+ <span class="truncate"
134
+ >${() => labelFor(selection.current()) ?? props.placeholder ?? "Select…"}</span
135
+ >
136
+ ${ChevronsUpDownIcon({ class: "size-4 opacity-50" })}
137
+ </button>
138
+ <input type="hidden" name="${props.name}" .value="${() => selection.current() ?? ""}" />
139
+ </div>`;
140
+ });
@@ -0,0 +1,211 @@
1
+ /**
2
+ * Command — a searchable list of actions.
3
+ *
4
+ * The palette pattern: type to filter, arrows to move, Enter to run. Used on
5
+ * its own — inside a Popover, a Sheet, or a page — while `CommandDialog` wraps
6
+ * this same component in a modal for the full-screen palette.
7
+ *
8
+ * The filter is the interesting decision. `cmdk`, which shadcn wraps, scores
9
+ * matches with a fuzzy algorithm; nebula matches on substring, plus an
10
+ * optional `keywords` list per item. Fuzzy matching is impressive on a demo
11
+ * and frustrating in use — it surfaces items the user did not type anything
12
+ * resembling, and the ranking is impossible to predict. Substring plus
13
+ * keywords is predictable, and "s" reaching "Settings" is a keyword, not an
14
+ * algorithm.
15
+ *
16
+ * Focus stays in the search input the whole time; the highlighted item is
17
+ * named with `aria-activedescendant`. Moving DOM focus onto the list would
18
+ * take it out of the input and the next keystroke would go nowhere.
19
+ */
20
+
21
+ import { component, html, signal } from "@c9up/aurora";
22
+ import type { Child } from "../lib/children.js";
23
+ import { cn } from "../lib/cn.js";
24
+ import { SearchIcon } from "../lib/icons.js";
25
+ import { uid } from "../lib/id.js";
26
+ import { type Reactive, read } from "../lib/props.js";
27
+
28
+ export interface CommandItem {
29
+ /** Stable key. */
30
+ value: string;
31
+ label: string;
32
+ icon?: Child;
33
+ /** Extra terms that should match this item. */
34
+ keywords?: readonly string[];
35
+ shortcut?: string;
36
+ disabled?: boolean;
37
+ group?: string;
38
+ onSelect?: () => void;
39
+ }
40
+
41
+ export interface CommandProps {
42
+ items: readonly CommandItem[];
43
+ placeholder?: string;
44
+ /** Shown when the filter matches nothing. */
45
+ emptyMessage?: Child;
46
+ /** Replace the built-in substring filter. */
47
+ filter?: (item: CommandItem, query: string) => boolean;
48
+ class?: Reactive<string>;
49
+ onSelect?: (item: CommandItem) => void;
50
+ }
51
+
52
+ /** Substring over the label, then over each keyword. */
53
+ export function defaultFilter(item: CommandItem, query: string): boolean {
54
+ if (query === "") return true;
55
+ const needle = query.toLowerCase();
56
+ if (item.label.toLowerCase().includes(needle)) return true;
57
+ return (item.keywords ?? []).some((keyword) =>
58
+ keyword.toLowerCase().includes(needle),
59
+ );
60
+ }
61
+
62
+ export const Command = component<CommandProps>((props) => {
63
+ const inputId = uid("command-input");
64
+ const listId = uid("command-list");
65
+ const itemIds = new Map<string, string>();
66
+ for (const item of props.items) itemIds.set(item.value, uid("command-item"));
67
+
68
+ const query = signal("");
69
+ const active = signal<string | undefined>(undefined);
70
+
71
+ function matches(): readonly CommandItem[] {
72
+ const filter = props.filter ?? defaultFilter;
73
+ return props.items.filter(
74
+ (item) => item.disabled !== true && filter(item, query()),
75
+ );
76
+ }
77
+
78
+ function run(item: CommandItem): void {
79
+ if (item.disabled === true) return;
80
+ item.onSelect?.();
81
+ props.onSelect?.(item);
82
+ }
83
+
84
+ function move(delta: number): void {
85
+ const list = matches();
86
+ if (list.length === 0) return;
87
+ const from = list.findIndex((item) => item.value === active());
88
+ const next = from === -1 ? (delta > 0 ? 0 : list.length - 1) : from + delta;
89
+ // Wrapping is right here, unlike in a menu: a palette is a search result
90
+ // list and the user is scanning it, not navigating a fixed structure.
91
+ const target = list[(next + list.length) % list.length];
92
+ if (target !== undefined) {
93
+ active(target.value);
94
+ scrollIntoView(target.value);
95
+ }
96
+ }
97
+
98
+ function scrollIntoView(value: string): void {
99
+ const id = itemIds.get(value);
100
+ if (id === undefined) return;
101
+ document.getElementById(id)?.scrollIntoView({ block: "nearest" });
102
+ }
103
+
104
+ function onKeyDown(event: KeyboardEvent): void {
105
+ if (event.key === "ArrowDown") {
106
+ event.preventDefault();
107
+ move(1);
108
+ } else if (event.key === "ArrowUp") {
109
+ event.preventDefault();
110
+ move(-1);
111
+ } else if (event.key === "Enter") {
112
+ event.preventDefault();
113
+ const current = matches().find((item) => item.value === active());
114
+ if (current !== undefined) run(current);
115
+ }
116
+ }
117
+
118
+ function onInput(event: Event): void {
119
+ const target = event.target;
120
+ if (!(target instanceof HTMLInputElement)) return;
121
+ query(target.value);
122
+ // Re-point the highlight at the first result: leaving it on an item the
123
+ // new query filtered out means Enter runs something invisible.
124
+ active(matches()[0]?.value);
125
+ }
126
+
127
+ return html`<div
128
+ data-slot="command"
129
+ class="${() =>
130
+ cn(
131
+ "bg-popover text-popover-foreground flex size-full flex-col overflow-hidden rounded-md",
132
+ read(props.class),
133
+ )}"
134
+ >
135
+ <div data-slot="command-input-wrapper" class="flex h-9 items-center gap-2 border-b px-3">
136
+ ${SearchIcon({ class: "size-4 shrink-0 opacity-50" })}
137
+ <input
138
+ data-slot="command-input"
139
+ id="${inputId}"
140
+ type="text"
141
+ role="combobox"
142
+ autocomplete="off"
143
+ aria-expanded="true"
144
+ aria-controls="${listId}"
145
+ aria-activedescendant="${() => {
146
+ const current = active();
147
+ return current === undefined ? undefined : itemIds.get(current);
148
+ }}"
149
+ placeholder="${props.placeholder ?? "Type a command or search…"}"
150
+ class="placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none disabled:cursor-not-allowed disabled:opacity-50"
151
+ @input="${onInput}"
152
+ @keydown="${onKeyDown}"
153
+ />
154
+ </div>
155
+ <div
156
+ data-slot="command-list"
157
+ id="${listId}"
158
+ role="listbox"
159
+ class="max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto p-1"
160
+ >
161
+ ${() => renderResults()}
162
+ </div>
163
+ </div>`;
164
+
165
+ function renderResults(): Child {
166
+ const list = matches();
167
+ if (list.length === 0) {
168
+ return html`<div
169
+ data-slot="command-empty"
170
+ role="presentation"
171
+ class="py-6 text-center text-sm"
172
+ >${props.emptyMessage ?? "No results found."}</div>`;
173
+ }
174
+
175
+ let lastGroup: string | undefined;
176
+ return list.map((item) => {
177
+ const heading =
178
+ item.group !== undefined && item.group !== lastGroup
179
+ ? html`<div
180
+ role="presentation"
181
+ class="text-muted-foreground px-2 py-1.5 text-xs font-medium"
182
+ >${item.group}</div>`
183
+ : null;
184
+ lastGroup = item.group;
185
+ return [heading, renderItem(item)];
186
+ });
187
+ }
188
+
189
+ function renderItem(item: CommandItem): Child {
190
+ return html`<div
191
+ role="option"
192
+ data-slot="command-item"
193
+ id="${itemIds.get(item.value)}"
194
+ aria-selected="${() => (active() === item.value ? "true" : "false")}"
195
+ data-active="${() => (active() === item.value ? "" : undefined)}"
196
+ class="relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none data-[active]:bg-accent data-[active]:text-accent-foreground [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4"
197
+ @click="${() => run(item)}"
198
+ @pointerenter="${() => active(item.value)}"
199
+ >
200
+ ${item.icon}
201
+ <span class="flex-1 truncate">${item.label}</span>
202
+ ${
203
+ item.shortcut === undefined
204
+ ? null
205
+ : html`<span class="text-muted-foreground ml-auto text-xs tracking-widest"
206
+ >${item.shortcut}</span
207
+ >`
208
+ }
209
+ </div>`;
210
+ }
211
+ });