@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,235 @@
1
+ /**
2
+ * Questionnaire — a survey, one question per step.
3
+ *
4
+ * Composed rather than reimplemented: `RadioGroup`, `Checkbox`, `Input`,
5
+ * `Textarea`, `Progress` and `Button` already exist, so this file is the state
6
+ * machine over them — which question is showing, what has been answered, and
7
+ * whether the reader may move on.
8
+ *
9
+ * Three decisions worth stating, because each has an obvious wrong answer:
10
+ *
11
+ * **The step counter is announced.** A visual progress bar tells a sighted
12
+ * reader they are on question 3 of 8. `aria-live="polite"` on the counter is
13
+ * what tells everyone else, and without it the survey is a series of
14
+ * unexplained page changes.
15
+ *
16
+ * **Answers survive going back.** Moving to the previous question and forward
17
+ * again keeps what was typed. The alternative — clearing on navigation —
18
+ * punishes the reader for checking what they said a moment ago.
19
+ *
20
+ * **A required question blocks Next, and says why.** Disabling the button
21
+ * silently is the common version, and it leaves the reader clicking a dead
22
+ * control with no explanation. The message appears only after an attempt, so
23
+ * the survey does not open by telling you that you have done nothing wrong yet.
24
+ */
25
+ import { component, html, signal } from "@c9up/aurora";
26
+ import { Button } from "../atoms/Button.js";
27
+ import { Checkbox } from "../atoms/Checkbox.js";
28
+ import { Input } from "../atoms/Input.js";
29
+ import { Progress } from "../atoms/Progress.js";
30
+ import { Textarea } from "../atoms/Textarea.js";
31
+ import { cn } from "../lib/cn.js";
32
+ import { uid } from "../lib/id.js";
33
+ import { read } from "../lib/props.js";
34
+ import { RadioGroup } from "../molecules/RadioGroup.js";
35
+ function isSingle(question) {
36
+ return question.type === "single";
37
+ }
38
+ function isMultiple(question) {
39
+ return question.type === "multiple";
40
+ }
41
+ /** Narrow an answer to the set form, for a multiple-choice question. */
42
+ function asSet(answer) {
43
+ if (answer === undefined)
44
+ return [];
45
+ return typeof answer === "string" ? [answer] : answer;
46
+ }
47
+ /** Narrow an answer to the single form. */
48
+ function asValue(answer) {
49
+ if (answer === undefined)
50
+ return "";
51
+ return typeof answer === "string" ? answer : (answer[0] ?? "");
52
+ }
53
+ /** Has this question been answered at all? */
54
+ export function isAnswered(question, answer) {
55
+ if (answer === undefined)
56
+ return false;
57
+ if (isMultiple(question))
58
+ return asSet(answer).length > 0;
59
+ return asValue(answer).trim() !== "";
60
+ }
61
+ export const Questionnaire = component((props) => {
62
+ const total = props.questions.length;
63
+ const step = signal(0);
64
+ const answers = signal({});
65
+ /** Set only after a blocked Next, so the survey does not open scolding. */
66
+ const attempted = signal(false);
67
+ const counterId = uid("questionnaire-counter");
68
+ const current = () => props.questions[step()];
69
+ function answerOf(question) {
70
+ return answers()[question.id];
71
+ }
72
+ function record(question, answer) {
73
+ answers({ ...answers(), [question.id]: answer });
74
+ attempted(false);
75
+ props.onAnswerChange?.(question.id, answer);
76
+ }
77
+ function toggleChoice(question, value) {
78
+ const chosen = asSet(answerOf(question));
79
+ record(question, chosen.includes(value)
80
+ ? chosen.filter((entry) => entry !== value)
81
+ : [...chosen, value]);
82
+ }
83
+ function canAdvance() {
84
+ const question = current();
85
+ if (question === undefined)
86
+ return false;
87
+ if (question.required !== true)
88
+ return true;
89
+ return isAnswered(question, answerOf(question));
90
+ }
91
+ function advance() {
92
+ if (!canAdvance()) {
93
+ attempted(true);
94
+ return;
95
+ }
96
+ attempted(false);
97
+ if (step() < total - 1)
98
+ step(step() + 1);
99
+ else
100
+ props.onComplete(answers());
101
+ }
102
+ function goBack() {
103
+ attempted(false);
104
+ if (step() > 0)
105
+ step(step() - 1);
106
+ }
107
+ function skip() {
108
+ attempted(false);
109
+ if (step() < total - 1)
110
+ step(step() + 1);
111
+ else
112
+ props.onComplete(answers());
113
+ }
114
+ const isLast = () => step() === total - 1;
115
+ return html `<section
116
+ data-slot="questionnaire"
117
+ aria-describedby="${counterId}"
118
+ class="${() => cn("flex w-full max-w-xl flex-col gap-6", read(props.class))}"
119
+ >
120
+ ${props.showProgress === false
121
+ ? null
122
+ : html `<div class="flex flex-col gap-2">
123
+ ${Progress({
124
+ value: () => step() + 1,
125
+ max: total,
126
+ label: "Questionnaire progress",
127
+ })}
128
+ <p
129
+ id="${counterId}"
130
+ aria-live="polite"
131
+ class="text-muted-foreground text-xs tabular-nums"
132
+ >${() => `Question ${step() + 1} of ${total}`}</p>
133
+ </div>`}
134
+
135
+ ${() => renderQuestion()}
136
+
137
+ <div class="flex items-center justify-between gap-2">
138
+ ${Button({
139
+ variant: "ghost",
140
+ disabled: () => step() === 0,
141
+ onClick: goBack,
142
+ children: props.backLabel ?? "Back",
143
+ })}
144
+ <div class="flex items-center gap-2">
145
+ ${() => props.skippable === false || current()?.required === true
146
+ ? null
147
+ : Button({
148
+ variant: "ghost",
149
+ onClick: skip,
150
+ children: props.skipLabel ?? "Skip",
151
+ })}
152
+ ${Button({
153
+ onClick: advance,
154
+ children: () => isLast()
155
+ ? (props.submitLabel ?? "Submit")
156
+ : (props.nextLabel ?? "Next"),
157
+ })}
158
+ </div>
159
+ </div>
160
+ </section>`;
161
+ function renderQuestion() {
162
+ const question = current();
163
+ if (question === undefined)
164
+ return null;
165
+ const blocked = attempted() && !canAdvance();
166
+ const errorId = `${question.id}-required`;
167
+ return html `<div data-slot="questionnaire-question" class="flex flex-col gap-3">
168
+ <div class="flex flex-col gap-1">
169
+ <h2 class="text-lg font-medium">
170
+ ${question.prompt}${question.required === true
171
+ ? html `<span aria-hidden="true" class="text-destructive"> *</span>`
172
+ : null}
173
+ </h2>
174
+ ${question.description === undefined
175
+ ? null
176
+ : html `<p class="text-muted-foreground text-sm">${question.description}</p>`}
177
+ </div>
178
+ ${renderControl(question, errorId, blocked)}
179
+ <p
180
+ id="${errorId}"
181
+ role="alert"
182
+ class="text-destructive text-sm empty:hidden"
183
+ >${blocked ? "This question needs an answer before you can continue." : ""}</p>
184
+ </div>`;
185
+ }
186
+ function renderControl(question, errorId, blocked) {
187
+ if (isSingle(question)) {
188
+ return RadioGroup({
189
+ name: question.id,
190
+ options: question.options.map((option) => ({
191
+ value: option.value,
192
+ label: option.label,
193
+ description: option.description,
194
+ })),
195
+ value: () => asValue(answerOf(question)),
196
+ onValueChange: (value) => record(question, value),
197
+ });
198
+ }
199
+ if (isMultiple(question)) {
200
+ return html `<div role="group" class="grid gap-3">
201
+ ${question.options.map((option) => {
202
+ const id = uid("questionnaire-choice");
203
+ return html `<div class="flex items-start gap-3">
204
+ ${Checkbox({
205
+ id,
206
+ name: question.id,
207
+ value: option.value,
208
+ checked: () => asSet(answerOf(question)).includes(option.value),
209
+ onCheckedChange: () => toggleChoice(question, option.value),
210
+ })}
211
+ <div class="grid gap-1 leading-none">
212
+ <label for="${id}" class="text-sm leading-none font-medium select-none"
213
+ >${option.label}</label
214
+ >
215
+ ${option.description === undefined
216
+ ? null
217
+ : html `<p class="text-muted-foreground text-sm">${option.description}</p>`}
218
+ </div>
219
+ </div>`;
220
+ })}
221
+ </div>`;
222
+ }
223
+ const shared = {
224
+ name: question.id,
225
+ placeholder: question.placeholder,
226
+ invalid: blocked,
227
+ describedBy: errorId,
228
+ value: () => asValue(answerOf(question)),
229
+ onInput: (value) => record(question, value),
230
+ };
231
+ return question.multiline === true
232
+ ? Textarea({ ...shared, rows: 4 })
233
+ : Input(shared);
234
+ }
235
+ });
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Select — choose one option from a list.
3
+ *
4
+ * A custom listbox, not a styled `<select>`. This is the one place nebula
5
+ * gives up native behaviour, and the reason is that a `<select>` popup is
6
+ * drawn by the operating system: its font, its colours and its checkmarks
7
+ * cannot be styled at all, and options cannot contain anything but text. A
8
+ * design system that can style every control except this one is not a design
9
+ * system.
10
+ *
11
+ * What that costs is everything the native control was doing for free, and it
12
+ * all has to be put back:
13
+ *
14
+ * - `role="combobox"` on the trigger, `role="listbox"` on the panel,
15
+ * `role="option"` with `aria-selected` on each entry.
16
+ * - `aria-activedescendant` rather than moving DOM focus — the listbox pattern
17
+ * keeps focus on the trigger and *names* the highlighted option, which is
18
+ * what lets a screen reader announce it without the focus ring jumping.
19
+ * - Type-ahead, arrows, Home/End, Enter and Escape.
20
+ * - A hidden input, so the control still posts with a plain HTML form.
21
+ *
22
+ * The panel is sized to the trigger and capped at the space available below
23
+ * it, so a hundred-option list scrolls rather than running off the screen.
24
+ */
25
+ import { type Reactive } from "../lib/props.js";
26
+ export interface SelectOption {
27
+ value: string;
28
+ label: string;
29
+ disabled?: boolean;
30
+ /** Groups consecutive options under a heading. */
31
+ group?: string;
32
+ }
33
+ export interface SelectProps {
34
+ options: readonly SelectOption[];
35
+ name?: string;
36
+ value?: Reactive<string | undefined>;
37
+ defaultValue?: string;
38
+ placeholder?: string;
39
+ disabled?: Reactive<boolean>;
40
+ invalid?: Reactive<boolean>;
41
+ required?: boolean;
42
+ class?: Reactive<string>;
43
+ contentClass?: Reactive<string>;
44
+ onValueChange?: (value: string) => void;
45
+ }
46
+ export declare const selectTriggerClasses = "border-input dark:bg-input/30 dark:hover:bg-input/50 flex h-9 w-fit min-w-0 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
47
+ export declare const Select: (props?: SelectProps | undefined) => import("@c9up/aurora").TemplateResult;
@@ -0,0 +1,250 @@
1
+ /**
2
+ * Select — choose one option from a list.
3
+ *
4
+ * A custom listbox, not a styled `<select>`. This is the one place nebula
5
+ * gives up native behaviour, and the reason is that a `<select>` popup is
6
+ * drawn by the operating system: its font, its colours and its checkmarks
7
+ * cannot be styled at all, and options cannot contain anything but text. A
8
+ * design system that can style every control except this one is not a design
9
+ * system.
10
+ *
11
+ * What that costs is everything the native control was doing for free, and it
12
+ * all has to be put back:
13
+ *
14
+ * - `role="combobox"` on the trigger, `role="listbox"` on the panel,
15
+ * `role="option"` with `aria-selected` on each entry.
16
+ * - `aria-activedescendant` rather than moving DOM focus — the listbox pattern
17
+ * keeps focus on the trigger and *names* the highlighted option, which is
18
+ * what lets a screen reader announce it without the focus ring jumping.
19
+ * - Type-ahead, arrows, Home/End, Enter and Escape.
20
+ * - A hidden input, so the control still posts with a plain HTML form.
21
+ *
22
+ * The panel is sized to the trigger and capped at the space available below
23
+ * it, so a hundred-option list scrolls rather than running off the screen.
24
+ */
25
+ import { component, html, signal } from "@c9up/aurora";
26
+ import { cn } from "../lib/cn.js";
27
+ import { CheckIcon, ChevronDownIcon } from "../lib/icons.js";
28
+ import { uid } from "../lib/id.js";
29
+ import { zoomInOut } from "../lib/motion.js";
30
+ import { read, readOr } from "../lib/props.js";
31
+ import { controllable } from "../primitives/controllable.js";
32
+ import { floatingSurface } from "../primitives/floatingSurface.js";
33
+ import { focusSilently } from "../primitives/focusable.js";
34
+ export const selectTriggerClasses = "border-input dark:bg-input/30 dark:hover:bg-input/50 flex h-9 w-fit min-w-0 items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[placeholder]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
35
+ export const Select = component((props) => {
36
+ const triggerId = uid("select-trigger");
37
+ const listId = uid("select-list");
38
+ const optionIds = new Map();
39
+ for (const option of props.options)
40
+ optionIds.set(option.value, uid("select-option"));
41
+ const open = signal(false);
42
+ const selection = controllable({
43
+ value: props.value,
44
+ initial: props.defaultValue,
45
+ onChange: (next) => {
46
+ if (next !== undefined)
47
+ props.onValueChange?.(next);
48
+ },
49
+ });
50
+ /** The option the keyboard is on. Distinct from the selected one. */
51
+ const active = signal(undefined);
52
+ const enabled = () => props.options.filter((option) => option.disabled !== true);
53
+ function labelFor(value) {
54
+ return props.options.find((option) => option.value === value)?.label;
55
+ }
56
+ function choose(value) {
57
+ selection.set(value);
58
+ active(value);
59
+ open(false);
60
+ focusSilently(document.getElementById(triggerId));
61
+ }
62
+ function moveActive(delta) {
63
+ const list = enabled();
64
+ if (list.length === 0)
65
+ return;
66
+ const from = list.findIndex((option) => option.value === active());
67
+ const next = from === -1 ? (delta > 0 ? 0 : list.length - 1) : from + delta;
68
+ const clamped = Math.min(Math.max(next, 0), list.length - 1);
69
+ const target = list[clamped];
70
+ if (target !== undefined)
71
+ active(target.value);
72
+ }
73
+ /**
74
+ * Type-ahead over the option *data*, not over elements.
75
+ *
76
+ * The shared primitive walks DOM nodes, which works for menus where focus
77
+ * moves. Here focus stays on the trigger and only `aria-activedescendant`
78
+ * moves, so the search runs over the options array instead.
79
+ */
80
+ let buffer = "";
81
+ let bufferTimer;
82
+ function seekByText(character) {
83
+ buffer += character.toLowerCase();
84
+ if (bufferTimer !== undefined)
85
+ clearTimeout(bufferTimer);
86
+ bufferTimer = setTimeout(() => {
87
+ buffer = "";
88
+ }, 1000);
89
+ const list = enabled();
90
+ const from = list.findIndex((option) => option.value === active());
91
+ for (let i = 1; i <= list.length; i += 1) {
92
+ const option = list[(Math.max(from, 0) + i) % list.length];
93
+ if (option === undefined)
94
+ continue;
95
+ if (option.label.toLowerCase().startsWith(buffer)) {
96
+ active(option.value);
97
+ return;
98
+ }
99
+ }
100
+ }
101
+ function onTriggerKeyDown(event) {
102
+ if (readOr(props.disabled, false))
103
+ return;
104
+ if (!open()) {
105
+ if (event.key === "ArrowDown" ||
106
+ event.key === "Enter" ||
107
+ event.key === " ") {
108
+ event.preventDefault();
109
+ active(selection.current() ?? enabled()[0]?.value);
110
+ open(true);
111
+ }
112
+ return;
113
+ }
114
+ if (event.key === "ArrowDown") {
115
+ event.preventDefault();
116
+ moveActive(1);
117
+ }
118
+ else if (event.key === "ArrowUp") {
119
+ event.preventDefault();
120
+ moveActive(-1);
121
+ }
122
+ else if (event.key === "Home") {
123
+ event.preventDefault();
124
+ active(enabled()[0]?.value);
125
+ }
126
+ else if (event.key === "End") {
127
+ event.preventDefault();
128
+ active(enabled()[enabled().length - 1]?.value);
129
+ }
130
+ else if (event.key === "Enter" || event.key === " ") {
131
+ event.preventDefault();
132
+ const current = active();
133
+ if (current !== undefined)
134
+ choose(current);
135
+ }
136
+ else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
137
+ seekByText(event.key);
138
+ }
139
+ }
140
+ floatingSurface({
141
+ anchor: () => document.getElementById(triggerId),
142
+ open: () => open(),
143
+ onClose: () => open(false),
144
+ placement: "bottom-start",
145
+ offset: 4,
146
+ matchWidth: true,
147
+ // Focus never enters the panel — the listbox pattern keeps it on the
148
+ // trigger — so nothing here traps or moves it.
149
+ content: renderList,
150
+ onOpened: scrollActiveIntoView,
151
+ });
152
+ function renderList() {
153
+ let lastGroup;
154
+ return html `<div
155
+ data-slot="select-content"
156
+ id="${listId}"
157
+ role="listbox"
158
+ aria-labelledby="${triggerId}"
159
+ class="${cn("bg-popover text-popover-foreground z-50 max-h-(--nebula-available-height) min-w-(--nebula-anchor-width) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md", zoomInOut, read(props.contentClass))}"
160
+ >
161
+ ${props.options.map((option) => {
162
+ const heading = option.group !== undefined && option.group !== lastGroup
163
+ ? html `<div class="text-muted-foreground px-2 py-1.5 text-xs">${option.group}</div>`
164
+ : null;
165
+ lastGroup = option.group;
166
+ return [heading, renderOption(option)];
167
+ })}
168
+ </div>`;
169
+ }
170
+ function renderOption(option) {
171
+ const chosen = () => selection.current() === option.value;
172
+ return html `<div
173
+ role="option"
174
+ id="${optionIds.get(option.value)}"
175
+ data-slot="select-item"
176
+ data-value="${option.value}"
177
+ aria-selected="${() => (chosen() ? "true" : "false")}"
178
+ data-active="${() => (active() === option.value ? "" : undefined)}"
179
+ data-disabled="${option.disabled === true ? "" : undefined}"
180
+ class="relative flex w-full cursor-default select-none items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none data-[active]:bg-accent data-[active]:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50"
181
+ @click="${() => {
182
+ if (option.disabled !== true)
183
+ choose(option.value);
184
+ }}"
185
+ @pointerenter="${() => {
186
+ if (option.disabled !== true)
187
+ active(option.value);
188
+ }}"
189
+ >
190
+ <span class="flex-1 truncate">${option.label}</span>
191
+ <span class="absolute right-2 flex size-3.5 items-center justify-center">
192
+ ${() => (chosen() ? CheckIcon({ class: "size-4" }) : null)}
193
+ </span>
194
+ </div>`;
195
+ }
196
+ /**
197
+ * Bring the highlighted option into view when the panel opens.
198
+ *
199
+ * Opening a two-hundred-option select on the one already chosen and showing
200
+ * the top of the list is the standard failure of a custom select.
201
+ */
202
+ function scrollActiveIntoView(panel) {
203
+ const current = active() ?? selection.current();
204
+ if (current === undefined)
205
+ return;
206
+ const id = optionIds.get(current);
207
+ if (id === undefined)
208
+ return;
209
+ panel
210
+ .querySelector(`#${CSS.escape(id)}`)
211
+ ?.scrollIntoView({ block: "nearest" });
212
+ }
213
+ return html `<div data-slot="select" class="${() => cn("inline-flex", read(props.class))}">
214
+ <button
215
+ type="button"
216
+ data-slot="select-trigger"
217
+ id="${triggerId}"
218
+ role="combobox"
219
+ aria-haspopup="listbox"
220
+ aria-expanded="${() => (open() ? "true" : "false")}"
221
+ aria-controls="${() => (open() ? listId : undefined)}"
222
+ aria-activedescendant="${() => {
223
+ const current = active();
224
+ return open() && current !== undefined
225
+ ? optionIds.get(current)
226
+ : undefined;
227
+ }}"
228
+ aria-invalid="${() => (read(props.invalid) === true ? "true" : undefined)}"
229
+ aria-required="${props.required === true ? "true" : undefined}"
230
+ data-placeholder="${() => (selection.current() === undefined ? "" : undefined)}"
231
+ ?disabled="${() => readOr(props.disabled, false)}"
232
+ class="${() => cn(selectTriggerClasses, read(props.class))}"
233
+ @click="${() => {
234
+ active(selection.current() ?? enabled()[0]?.value);
235
+ open(!open());
236
+ }}"
237
+ @keydown="${onTriggerKeyDown}"
238
+ >
239
+ <span class="truncate"
240
+ >${() => labelFor(selection.current()) ?? props.placeholder ?? "Select…"}</span
241
+ >
242
+ ${ChevronDownIcon({ class: "size-4 opacity-50" })}
243
+ </button>
244
+ <input
245
+ type="hidden"
246
+ name="${props.name}"
247
+ .value="${() => selection.current() ?? ""}"
248
+ />
249
+ </div>`;
250
+ });
@@ -0,0 +1,34 @@
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
+ import type { Child } from "../lib/children.js";
16
+ import { type Slot } from "../lib/children.js";
17
+ import { type Reactive } from "../lib/props.js";
18
+ import type { Side } from "../primitives/floating.js";
19
+ export interface SheetProps {
20
+ trigger?: Slot;
21
+ title: Child;
22
+ description?: Child;
23
+ children?: Slot;
24
+ footer?: Slot;
25
+ /** Which edge it comes from. Default `"right"`. */
26
+ side?: Side;
27
+ open?: Reactive<boolean>;
28
+ defaultOpen?: boolean;
29
+ onOpenChange?: (open: boolean) => void;
30
+ srOnlyTitle?: boolean;
31
+ triggerClass?: Reactive<string>;
32
+ contentClass?: Reactive<string>;
33
+ }
34
+ export declare const Sheet: (props?: SheetProps | undefined) => import("@c9up/aurora").TemplateResult;
@@ -0,0 +1,107 @@
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
+ import { component, html } from "@c9up/aurora";
16
+ import { slot } from "../lib/children.js";
17
+ import { cn } from "../lib/cn.js";
18
+ import { XIcon } from "../lib/icons.js";
19
+ import { uid } from "../lib/id.js";
20
+ import { fadeInOut, slideFrom } from "../lib/motion.js";
21
+ import { read } from "../lib/props.js";
22
+ import { controllable } from "../primitives/controllable.js";
23
+ import { modalSurface } from "../primitives/modalSurface.js";
24
+ import { dialogBackdropClasses } from "./Dialog.js";
25
+ /** Edge placement and the axis the panel is sized on. */
26
+ function panelClassesFor(side) {
27
+ if (side === "left") {
28
+ return "inset-y-0 left-0 h-full w-3/4 border-r sm:max-w-sm";
29
+ }
30
+ if (side === "right") {
31
+ return "inset-y-0 right-0 h-full w-3/4 border-l sm:max-w-sm";
32
+ }
33
+ if (side === "top")
34
+ return "inset-x-0 top-0 h-auto border-b";
35
+ return "inset-x-0 bottom-0 h-auto border-t";
36
+ }
37
+ export const Sheet = component((props) => {
38
+ const side = props.side ?? "right";
39
+ const triggerId = uid("sheet-trigger");
40
+ const panelId = uid("sheet-panel");
41
+ const titleId = uid("sheet-title");
42
+ const descriptionId = uid("sheet-description");
43
+ const state = controllable({
44
+ value: props.open,
45
+ initial: props.defaultOpen ?? false,
46
+ onChange: props.onOpenChange,
47
+ });
48
+ modalSurface({
49
+ open: () => state.current(),
50
+ onClose: () => state.set(false),
51
+ panel: (root) => root.querySelector(`#${CSS.escape(panelId)}`),
52
+ returnFocus: () => document.getElementById(triggerId),
53
+ content: () => html `<div data-slot="sheet-overlay" class="${cn("fixed inset-0 z-50", fadeInOut)}">
54
+ <div class="${dialogBackdropClasses}"></div>
55
+ <div
56
+ data-slot="sheet-content"
57
+ data-side="${side}"
58
+ id="${panelId}"
59
+ role="dialog"
60
+ aria-modal="true"
61
+ aria-labelledby="${titleId}"
62
+ aria-describedby="${props.description === undefined ? undefined : descriptionId}"
63
+ tabindex="-1"
64
+ class="${cn("bg-background fixed z-50 flex flex-col gap-4 p-6 shadow-lg outline-none", panelClassesFor(side), slideFrom(side), read(props.contentClass))}"
65
+ >
66
+ <div data-slot="sheet-header" class="flex flex-col gap-1.5">
67
+ <h2
68
+ id="${titleId}"
69
+ class="${cn("text-foreground font-semibold", props.srOnlyTitle === true ? "sr-only" : "")}"
70
+ >${props.title}</h2>
71
+ ${props.description === undefined
72
+ ? null
73
+ : html `<p id="${descriptionId}" class="text-muted-foreground text-sm">
74
+ ${props.description}
75
+ </p>`}
76
+ </div>
77
+ <div data-slot="sheet-body" class="flex-1 overflow-y-auto">${slot(props.children)}</div>
78
+ ${props.footer === undefined
79
+ ? null
80
+ : html `<div data-slot="sheet-footer" class="mt-auto flex flex-col gap-2">
81
+ ${slot(props.footer)}
82
+ </div>`}
83
+ <button
84
+ type="button"
85
+ data-slot="sheet-close"
86
+ 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"
87
+ @click="${() => state.set(false)}"
88
+ >
89
+ ${XIcon({ class: "size-4" })}
90
+ <span class="sr-only">Close</span>
91
+ </button>
92
+ </div>
93
+ </div>`,
94
+ });
95
+ if (props.trigger === undefined) {
96
+ return html `<span data-slot="sheet" hidden></span>`;
97
+ }
98
+ return html `<button
99
+ type="button"
100
+ data-slot="sheet-trigger"
101
+ id="${triggerId}"
102
+ aria-haspopup="dialog"
103
+ aria-expanded="${() => (state.current() ? "true" : "false")}"
104
+ class="${() => cn(read(props.triggerClass))}"
105
+ @click="${() => state.set(true)}"
106
+ >${slot(props.trigger)}</button>`;
107
+ });