@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,322 @@
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
+
26
+ import { component, html, signal } from "@c9up/aurora";
27
+ import { Button } from "../atoms/Button.js";
28
+ import { Checkbox } from "../atoms/Checkbox.js";
29
+ import { Input } from "../atoms/Input.js";
30
+ import { Progress } from "../atoms/Progress.js";
31
+ import { Textarea } from "../atoms/Textarea.js";
32
+ import type { Child } from "../lib/children.js";
33
+ import { cn } from "../lib/cn.js";
34
+ import { uid } from "../lib/id.js";
35
+ import { type Reactive, read } from "../lib/props.js";
36
+ import { RadioGroup } from "../molecules/RadioGroup.js";
37
+
38
+ export interface QuestionOption {
39
+ value: string;
40
+ label: Child;
41
+ description?: Child;
42
+ }
43
+
44
+ interface BaseQuestion {
45
+ /** Stable key. The answer is filed under it. */
46
+ id: string;
47
+ prompt: Child;
48
+ description?: Child;
49
+ /** Blocks Next until answered. */
50
+ required?: boolean;
51
+ }
52
+
53
+ export interface SingleChoiceQuestion extends BaseQuestion {
54
+ type: "single";
55
+ options: readonly QuestionOption[];
56
+ }
57
+
58
+ export interface MultipleChoiceQuestion extends BaseQuestion {
59
+ type: "multiple";
60
+ options: readonly QuestionOption[];
61
+ }
62
+
63
+ export interface FreeformQuestion extends BaseQuestion {
64
+ type: "text";
65
+ placeholder?: string;
66
+ /** Renders a textarea instead of a single-line input. */
67
+ multiline?: boolean;
68
+ }
69
+
70
+ export type Question =
71
+ | SingleChoiceQuestion
72
+ | MultipleChoiceQuestion
73
+ | FreeformQuestion;
74
+
75
+ /** One answer: a value for single/text, a set for multiple. */
76
+ export type Answer = string | readonly string[];
77
+ export type Answers = Readonly<Record<string, Answer>>;
78
+
79
+ export interface QuestionnaireProps {
80
+ questions: readonly Question[];
81
+ onComplete: (answers: Answers) => void;
82
+ onAnswerChange?: (id: string, answer: Answer) => void;
83
+ /** Allow skipping questions that are not required. Default `true`. */
84
+ skippable?: boolean;
85
+ showProgress?: boolean;
86
+ backLabel?: string;
87
+ nextLabel?: string;
88
+ skipLabel?: string;
89
+ submitLabel?: string;
90
+ class?: Reactive<string>;
91
+ }
92
+
93
+ function isSingle(question: Question): question is SingleChoiceQuestion {
94
+ return question.type === "single";
95
+ }
96
+
97
+ function isMultiple(question: Question): question is MultipleChoiceQuestion {
98
+ return question.type === "multiple";
99
+ }
100
+
101
+ /** Narrow an answer to the set form, for a multiple-choice question. */
102
+ function asSet(answer: Answer | undefined): readonly string[] {
103
+ if (answer === undefined) return [];
104
+ return typeof answer === "string" ? [answer] : answer;
105
+ }
106
+
107
+ /** Narrow an answer to the single form. */
108
+ function asValue(answer: Answer | undefined): string {
109
+ if (answer === undefined) return "";
110
+ return typeof answer === "string" ? answer : (answer[0] ?? "");
111
+ }
112
+
113
+ /** Has this question been answered at all? */
114
+ export function isAnswered(
115
+ question: Question,
116
+ answer: Answer | undefined,
117
+ ): boolean {
118
+ if (answer === undefined) return false;
119
+ if (isMultiple(question)) return asSet(answer).length > 0;
120
+ return asValue(answer).trim() !== "";
121
+ }
122
+
123
+ export const Questionnaire = component<QuestionnaireProps>((props) => {
124
+ const total = props.questions.length;
125
+ const step = signal(0);
126
+ const answers = signal<Answers>({});
127
+ /** Set only after a blocked Next, so the survey does not open scolding. */
128
+ const attempted = signal(false);
129
+ const counterId = uid("questionnaire-counter");
130
+
131
+ const current = (): Question | undefined => props.questions[step()];
132
+
133
+ function answerOf(question: Question): Answer | undefined {
134
+ return answers()[question.id];
135
+ }
136
+
137
+ function record(question: Question, answer: Answer): void {
138
+ answers({ ...answers(), [question.id]: answer });
139
+ attempted(false);
140
+ props.onAnswerChange?.(question.id, answer);
141
+ }
142
+
143
+ function toggleChoice(question: Question, value: string): void {
144
+ const chosen = asSet(answerOf(question));
145
+ record(
146
+ question,
147
+ chosen.includes(value)
148
+ ? chosen.filter((entry) => entry !== value)
149
+ : [...chosen, value],
150
+ );
151
+ }
152
+
153
+ function canAdvance(): boolean {
154
+ const question = current();
155
+ if (question === undefined) return false;
156
+ if (question.required !== true) return true;
157
+ return isAnswered(question, answerOf(question));
158
+ }
159
+
160
+ function advance(): void {
161
+ if (!canAdvance()) {
162
+ attempted(true);
163
+ return;
164
+ }
165
+ attempted(false);
166
+ if (step() < total - 1) step(step() + 1);
167
+ else props.onComplete(answers());
168
+ }
169
+
170
+ function goBack(): void {
171
+ attempted(false);
172
+ if (step() > 0) step(step() - 1);
173
+ }
174
+
175
+ function skip(): void {
176
+ attempted(false);
177
+ if (step() < total - 1) step(step() + 1);
178
+ else props.onComplete(answers());
179
+ }
180
+
181
+ const isLast = (): boolean => step() === total - 1;
182
+
183
+ return html`<section
184
+ data-slot="questionnaire"
185
+ aria-describedby="${counterId}"
186
+ class="${() => cn("flex w-full max-w-xl flex-col gap-6", read(props.class))}"
187
+ >
188
+ ${
189
+ props.showProgress === false
190
+ ? null
191
+ : html`<div class="flex flex-col gap-2">
192
+ ${Progress({
193
+ value: () => step() + 1,
194
+ max: total,
195
+ label: "Questionnaire progress",
196
+ })}
197
+ <p
198
+ id="${counterId}"
199
+ aria-live="polite"
200
+ class="text-muted-foreground text-xs tabular-nums"
201
+ >${() => `Question ${step() + 1} of ${total}`}</p>
202
+ </div>`
203
+ }
204
+
205
+ ${() => renderQuestion()}
206
+
207
+ <div class="flex items-center justify-between gap-2">
208
+ ${Button({
209
+ variant: "ghost",
210
+ disabled: () => step() === 0,
211
+ onClick: goBack,
212
+ children: props.backLabel ?? "Back",
213
+ })}
214
+ <div class="flex items-center gap-2">
215
+ ${() =>
216
+ props.skippable === false || current()?.required === true
217
+ ? null
218
+ : Button({
219
+ variant: "ghost",
220
+ onClick: skip,
221
+ children: props.skipLabel ?? "Skip",
222
+ })}
223
+ ${Button({
224
+ onClick: advance,
225
+ children: () =>
226
+ isLast()
227
+ ? (props.submitLabel ?? "Submit")
228
+ : (props.nextLabel ?? "Next"),
229
+ })}
230
+ </div>
231
+ </div>
232
+ </section>`;
233
+
234
+ function renderQuestion(): Child {
235
+ const question = current();
236
+ if (question === undefined) return null;
237
+
238
+ const blocked = attempted() && !canAdvance();
239
+ const errorId = `${question.id}-required`;
240
+
241
+ return html`<div data-slot="questionnaire-question" class="flex flex-col gap-3">
242
+ <div class="flex flex-col gap-1">
243
+ <h2 class="text-lg font-medium">
244
+ ${question.prompt}${
245
+ question.required === true
246
+ ? html`<span aria-hidden="true" class="text-destructive"> *</span>`
247
+ : null
248
+ }
249
+ </h2>
250
+ ${
251
+ question.description === undefined
252
+ ? null
253
+ : html`<p class="text-muted-foreground text-sm">${question.description}</p>`
254
+ }
255
+ </div>
256
+ ${renderControl(question, errorId, blocked)}
257
+ <p
258
+ id="${errorId}"
259
+ role="alert"
260
+ class="text-destructive text-sm empty:hidden"
261
+ >${blocked ? "This question needs an answer before you can continue." : ""}</p>
262
+ </div>`;
263
+ }
264
+
265
+ function renderControl(
266
+ question: Question,
267
+ errorId: string,
268
+ blocked: boolean,
269
+ ): Child {
270
+ if (isSingle(question)) {
271
+ return RadioGroup({
272
+ name: question.id,
273
+ options: question.options.map((option) => ({
274
+ value: option.value,
275
+ label: option.label,
276
+ description: option.description,
277
+ })),
278
+ value: () => asValue(answerOf(question)),
279
+ onValueChange: (value) => record(question, value),
280
+ });
281
+ }
282
+
283
+ if (isMultiple(question)) {
284
+ return html`<div role="group" class="grid gap-3">
285
+ ${question.options.map((option) => {
286
+ const id = uid("questionnaire-choice");
287
+ return html`<div class="flex items-start gap-3">
288
+ ${Checkbox({
289
+ id,
290
+ name: question.id,
291
+ value: option.value,
292
+ checked: () => asSet(answerOf(question)).includes(option.value),
293
+ onCheckedChange: () => toggleChoice(question, option.value),
294
+ })}
295
+ <div class="grid gap-1 leading-none">
296
+ <label for="${id}" class="text-sm leading-none font-medium select-none"
297
+ >${option.label}</label
298
+ >
299
+ ${
300
+ option.description === undefined
301
+ ? null
302
+ : html`<p class="text-muted-foreground text-sm">${option.description}</p>`
303
+ }
304
+ </div>
305
+ </div>`;
306
+ })}
307
+ </div>`;
308
+ }
309
+
310
+ const shared = {
311
+ name: question.id,
312
+ placeholder: question.placeholder,
313
+ invalid: blocked,
314
+ describedBy: errorId,
315
+ value: () => asValue(answerOf(question)),
316
+ onInput: (value: string) => record(question, value),
317
+ };
318
+ return question.multiline === true
319
+ ? Textarea({ ...shared, rows: 4 })
320
+ : Input(shared);
321
+ }
322
+ });
@@ -0,0 +1,286 @@
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
+
26
+ import { component, html, signal } from "@c9up/aurora";
27
+ import type { Child } from "../lib/children.js";
28
+ import { cn } from "../lib/cn.js";
29
+ import { CheckIcon, ChevronDownIcon } from "../lib/icons.js";
30
+ import { uid } from "../lib/id.js";
31
+ import { zoomInOut } from "../lib/motion.js";
32
+ import { type Reactive, read, readOr } from "../lib/props.js";
33
+ import { controllable } from "../primitives/controllable.js";
34
+ import { floatingSurface } from "../primitives/floatingSurface.js";
35
+ import { focusSilently } from "../primitives/focusable.js";
36
+
37
+ export interface SelectOption {
38
+ value: string;
39
+ label: string;
40
+ disabled?: boolean;
41
+ /** Groups consecutive options under a heading. */
42
+ group?: string;
43
+ }
44
+
45
+ export interface SelectProps {
46
+ options: readonly SelectOption[];
47
+ name?: string;
48
+ value?: Reactive<string | undefined>;
49
+ defaultValue?: string;
50
+ placeholder?: string;
51
+ disabled?: Reactive<boolean>;
52
+ invalid?: Reactive<boolean>;
53
+ required?: boolean;
54
+ class?: Reactive<string>;
55
+ contentClass?: Reactive<string>;
56
+ onValueChange?: (value: string) => void;
57
+ }
58
+
59
+ export const selectTriggerClasses =
60
+ "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";
61
+
62
+ export const Select = component<SelectProps>((props) => {
63
+ const triggerId = uid("select-trigger");
64
+ const listId = uid("select-list");
65
+ const optionIds = new Map<string, string>();
66
+ for (const option of props.options)
67
+ optionIds.set(option.value, uid("select-option"));
68
+
69
+ const open = signal(false);
70
+ const selection = controllable<string | undefined>({
71
+ value: props.value,
72
+ initial: props.defaultValue,
73
+ onChange: (next) => {
74
+ if (next !== undefined) props.onValueChange?.(next);
75
+ },
76
+ });
77
+ /** The option the keyboard is on. Distinct from the selected one. */
78
+ const active = signal<string | undefined>(undefined);
79
+
80
+ const enabled = (): readonly SelectOption[] =>
81
+ props.options.filter((option) => option.disabled !== true);
82
+
83
+ function labelFor(value: string | undefined): string | undefined {
84
+ return props.options.find((option) => option.value === value)?.label;
85
+ }
86
+
87
+ function choose(value: string): void {
88
+ selection.set(value);
89
+ active(value);
90
+ open(false);
91
+ focusSilently(document.getElementById(triggerId));
92
+ }
93
+
94
+ function moveActive(delta: number): void {
95
+ const list = enabled();
96
+ if (list.length === 0) return;
97
+ const from = list.findIndex((option) => option.value === active());
98
+ const next = from === -1 ? (delta > 0 ? 0 : list.length - 1) : from + delta;
99
+ const clamped = Math.min(Math.max(next, 0), list.length - 1);
100
+ const target = list[clamped];
101
+ if (target !== undefined) active(target.value);
102
+ }
103
+
104
+ /**
105
+ * Type-ahead over the option *data*, not over elements.
106
+ *
107
+ * The shared primitive walks DOM nodes, which works for menus where focus
108
+ * moves. Here focus stays on the trigger and only `aria-activedescendant`
109
+ * moves, so the search runs over the options array instead.
110
+ */
111
+ let buffer = "";
112
+ let bufferTimer: ReturnType<typeof setTimeout> | undefined;
113
+
114
+ function seekByText(character: string): void {
115
+ buffer += character.toLowerCase();
116
+ if (bufferTimer !== undefined) clearTimeout(bufferTimer);
117
+ bufferTimer = setTimeout(() => {
118
+ buffer = "";
119
+ }, 1000);
120
+
121
+ const list = enabled();
122
+ const from = list.findIndex((option) => option.value === active());
123
+ for (let i = 1; i <= list.length; i += 1) {
124
+ const option = list[(Math.max(from, 0) + i) % list.length];
125
+ if (option === undefined) continue;
126
+ if (option.label.toLowerCase().startsWith(buffer)) {
127
+ active(option.value);
128
+ return;
129
+ }
130
+ }
131
+ }
132
+
133
+ function onTriggerKeyDown(event: KeyboardEvent): void {
134
+ if (readOr(props.disabled, false)) return;
135
+
136
+ if (!open()) {
137
+ if (
138
+ event.key === "ArrowDown" ||
139
+ event.key === "Enter" ||
140
+ event.key === " "
141
+ ) {
142
+ event.preventDefault();
143
+ active(selection.current() ?? enabled()[0]?.value);
144
+ open(true);
145
+ }
146
+ return;
147
+ }
148
+
149
+ if (event.key === "ArrowDown") {
150
+ event.preventDefault();
151
+ moveActive(1);
152
+ } else if (event.key === "ArrowUp") {
153
+ event.preventDefault();
154
+ moveActive(-1);
155
+ } else if (event.key === "Home") {
156
+ event.preventDefault();
157
+ active(enabled()[0]?.value);
158
+ } else if (event.key === "End") {
159
+ event.preventDefault();
160
+ active(enabled()[enabled().length - 1]?.value);
161
+ } else if (event.key === "Enter" || event.key === " ") {
162
+ event.preventDefault();
163
+ const current = active();
164
+ if (current !== undefined) choose(current);
165
+ } else if (event.key.length === 1 && !event.metaKey && !event.ctrlKey) {
166
+ seekByText(event.key);
167
+ }
168
+ }
169
+
170
+ floatingSurface({
171
+ anchor: () => document.getElementById(triggerId),
172
+ open: () => open(),
173
+ onClose: () => open(false),
174
+ placement: "bottom-start",
175
+ offset: 4,
176
+ matchWidth: true,
177
+ // Focus never enters the panel — the listbox pattern keeps it on the
178
+ // trigger — so nothing here traps or moves it.
179
+ content: renderList,
180
+ onOpened: scrollActiveIntoView,
181
+ });
182
+
183
+ function renderList(): ReturnType<typeof html> {
184
+ let lastGroup: string | undefined;
185
+
186
+ return html`<div
187
+ data-slot="select-content"
188
+ id="${listId}"
189
+ role="listbox"
190
+ aria-labelledby="${triggerId}"
191
+ class="${cn(
192
+ "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",
193
+ zoomInOut,
194
+ read(props.contentClass),
195
+ )}"
196
+ >
197
+ ${props.options.map((option) => {
198
+ const heading =
199
+ option.group !== undefined && option.group !== lastGroup
200
+ ? html`<div class="text-muted-foreground px-2 py-1.5 text-xs">${option.group}</div>`
201
+ : null;
202
+ lastGroup = option.group;
203
+ return [heading, renderOption(option)];
204
+ })}
205
+ </div>`;
206
+ }
207
+
208
+ function renderOption(option: SelectOption): Child {
209
+ const chosen = (): boolean => selection.current() === option.value;
210
+ return html`<div
211
+ role="option"
212
+ id="${optionIds.get(option.value)}"
213
+ data-slot="select-item"
214
+ data-value="${option.value}"
215
+ aria-selected="${() => (chosen() ? "true" : "false")}"
216
+ data-active="${() => (active() === option.value ? "" : undefined)}"
217
+ data-disabled="${option.disabled === true ? "" : undefined}"
218
+ 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"
219
+ @click="${() => {
220
+ if (option.disabled !== true) choose(option.value);
221
+ }}"
222
+ @pointerenter="${() => {
223
+ if (option.disabled !== true) active(option.value);
224
+ }}"
225
+ >
226
+ <span class="flex-1 truncate">${option.label}</span>
227
+ <span class="absolute right-2 flex size-3.5 items-center justify-center">
228
+ ${() => (chosen() ? CheckIcon({ class: "size-4" }) : null)}
229
+ </span>
230
+ </div>`;
231
+ }
232
+
233
+ /**
234
+ * Bring the highlighted option into view when the panel opens.
235
+ *
236
+ * Opening a two-hundred-option select on the one already chosen and showing
237
+ * the top of the list is the standard failure of a custom select.
238
+ */
239
+ function scrollActiveIntoView(panel: HTMLElement): void {
240
+ const current = active() ?? selection.current();
241
+ if (current === undefined) return;
242
+ const id = optionIds.get(current);
243
+ if (id === undefined) return;
244
+ panel
245
+ .querySelector(`#${CSS.escape(id)}`)
246
+ ?.scrollIntoView({ block: "nearest" });
247
+ }
248
+
249
+ return html`<div data-slot="select" class="${() => cn("inline-flex", read(props.class))}">
250
+ <button
251
+ type="button"
252
+ data-slot="select-trigger"
253
+ id="${triggerId}"
254
+ role="combobox"
255
+ aria-haspopup="listbox"
256
+ aria-expanded="${() => (open() ? "true" : "false")}"
257
+ aria-controls="${() => (open() ? listId : undefined)}"
258
+ aria-activedescendant="${() => {
259
+ const current = active();
260
+ return open() && current !== undefined
261
+ ? optionIds.get(current)
262
+ : undefined;
263
+ }}"
264
+ aria-invalid="${() => (read(props.invalid) === true ? "true" : undefined)}"
265
+ aria-required="${props.required === true ? "true" : undefined}"
266
+ data-placeholder="${() => (selection.current() === undefined ? "" : undefined)}"
267
+ ?disabled="${() => readOr(props.disabled, false)}"
268
+ class="${() => cn(selectTriggerClasses, read(props.class))}"
269
+ @click="${() => {
270
+ active(selection.current() ?? enabled()[0]?.value);
271
+ open(!open());
272
+ }}"
273
+ @keydown="${onTriggerKeyDown}"
274
+ >
275
+ <span class="truncate"
276
+ >${() => labelFor(selection.current()) ?? props.placeholder ?? "Select…"}</span
277
+ >
278
+ ${ChevronDownIcon({ class: "size-4 opacity-50" })}
279
+ </button>
280
+ <input
281
+ type="hidden"
282
+ name="${props.name}"
283
+ .value="${() => selection.current() ?? ""}"
284
+ />
285
+ </div>`;
286
+ });