@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.
- package/LICENSE +21 -0
- package/README.md +256 -0
- package/dist/adapters/css.d.ts +33 -0
- package/dist/adapters/css.js +67 -0
- package/dist/adapters/index.d.ts +16 -0
- package/dist/adapters/index.js +27 -0
- package/dist/adapters/tailwind.d.ts +25 -0
- package/dist/adapters/tailwind.js +122 -0
- package/dist/adapters/types.d.ts +49 -0
- package/dist/adapters/types.js +21 -0
- package/dist/adapters/unocss.d.ts +17 -0
- package/dist/adapters/unocss.js +107 -0
- package/dist/atoms/AspectRatio.d.ts +17 -0
- package/dist/atoms/AspectRatio.js +19 -0
- package/dist/atoms/Avatar.d.ts +23 -0
- package/dist/atoms/Avatar.js +46 -0
- package/dist/atoms/Badge.d.ts +25 -0
- package/dist/atoms/Badge.js +28 -0
- package/dist/atoms/Button.d.ts +58 -0
- package/dist/atoms/Button.js +67 -0
- package/dist/atoms/Checkbox.d.ts +41 -0
- package/dist/atoms/Checkbox.js +47 -0
- package/dist/atoms/Input.d.ts +36 -0
- package/dist/atoms/Input.js +50 -0
- package/dist/atoms/Kbd.d.ts +14 -0
- package/dist/atoms/Kbd.js +17 -0
- package/dist/atoms/Label.d.ts +24 -0
- package/dist/atoms/Label.js +26 -0
- package/dist/atoms/Marker.d.ts +42 -0
- package/dist/atoms/Marker.js +55 -0
- package/dist/atoms/NativeSelect.d.ts +43 -0
- package/dist/atoms/NativeSelect.js +81 -0
- package/dist/atoms/Progress.d.ts +21 -0
- package/dist/atoms/Progress.js +48 -0
- package/dist/atoms/ScrollArea.d.ts +28 -0
- package/dist/atoms/ScrollArea.js +37 -0
- package/dist/atoms/Separator.d.ts +16 -0
- package/dist/atoms/Separator.js +21 -0
- package/dist/atoms/Skeleton.d.ts +13 -0
- package/dist/atoms/Skeleton.js +18 -0
- package/dist/atoms/Slider.d.ts +30 -0
- package/dist/atoms/Slider.js +40 -0
- package/dist/atoms/Spinner.d.ts +17 -0
- package/dist/atoms/Spinner.js +20 -0
- package/dist/atoms/Switch.d.ts +24 -0
- package/dist/atoms/Switch.js +41 -0
- package/dist/atoms/Textarea.d.ts +28 -0
- package/dist/atoms/Textarea.js +36 -0
- package/dist/atoms/Toggle.d.ts +38 -0
- package/dist/atoms/Toggle.js +45 -0
- package/dist/atoms/index.d.ts +32 -0
- package/dist/atoms/index.js +32 -0
- package/dist/cli/add.d.ts +44 -0
- package/dist/cli/add.js +99 -0
- package/dist/cli/index.d.ts +10 -0
- package/dist/cli/index.js +10 -0
- package/dist/cli/registry.d.ts +31 -0
- package/dist/cli/registry.js +83 -0
- package/dist/config.d.ts +81 -0
- package/dist/config.js +43 -0
- package/dist/configure.d.ts +34 -0
- package/dist/configure.js +85 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.js +41 -0
- package/dist/lib/children.d.ts +28 -0
- package/dist/lib/children.js +27 -0
- package/dist/lib/cn.d.ts +13 -0
- package/dist/lib/cn.js +13 -0
- package/dist/lib/cva.d.ts +83 -0
- package/dist/lib/cva.js +178 -0
- package/dist/lib/icons.d.ts +44 -0
- package/dist/lib/icons.js +79 -0
- package/dist/lib/id.d.ts +31 -0
- package/dist/lib/id.js +41 -0
- package/dist/lib/index.d.ts +12 -0
- package/dist/lib/index.js +12 -0
- package/dist/lib/motion.d.ts +31 -0
- package/dist/lib/motion.js +39 -0
- package/dist/lib/props.d.ts +48 -0
- package/dist/lib/props.js +59 -0
- package/dist/lib/styled.d.ts +31 -0
- package/dist/lib/styled.js +39 -0
- package/dist/molecules/Accordion.d.ts +35 -0
- package/dist/molecules/Accordion.js +117 -0
- package/dist/molecules/Alert.d.ts +30 -0
- package/dist/molecules/Alert.js +35 -0
- package/dist/molecules/Attachment.d.ts +39 -0
- package/dist/molecules/Attachment.js +103 -0
- package/dist/molecules/Breadcrumb.d.ts +29 -0
- package/dist/molecules/Breadcrumb.js +73 -0
- package/dist/molecules/Bubble.d.ts +56 -0
- package/dist/molecules/Bubble.js +92 -0
- package/dist/molecules/ButtonGroup.d.ts +23 -0
- package/dist/molecules/ButtonGroup.js +30 -0
- package/dist/molecules/Card.d.ts +17 -0
- package/dist/molecules/Card.js +17 -0
- package/dist/molecules/Collapsible.d.ts +40 -0
- package/dist/molecules/Collapsible.js +71 -0
- package/dist/molecules/Empty.d.ts +16 -0
- package/dist/molecules/Empty.js +16 -0
- package/dist/molecules/Field.d.ts +54 -0
- package/dist/molecules/Field.js +82 -0
- package/dist/molecules/InputGroup.d.ts +33 -0
- package/dist/molecules/InputGroup.js +35 -0
- package/dist/molecules/InputOTP.d.ts +33 -0
- package/dist/molecules/InputOTP.js +140 -0
- package/dist/molecules/Item.d.ts +41 -0
- package/dist/molecules/Item.js +48 -0
- package/dist/molecules/Message.d.ts +43 -0
- package/dist/molecules/Message.js +82 -0
- package/dist/molecules/Pagination.d.ts +41 -0
- package/dist/molecules/Pagination.js +144 -0
- package/dist/molecules/RadioGroup.d.ts +32 -0
- package/dist/molecules/RadioGroup.js +65 -0
- package/dist/molecules/Resizable.d.ts +37 -0
- package/dist/molecules/Resizable.js +136 -0
- package/dist/molecules/Table.d.ts +37 -0
- package/dist/molecules/Table.js +78 -0
- package/dist/molecules/Tabs.d.ts +37 -0
- package/dist/molecules/Tabs.js +109 -0
- package/dist/molecules/ToggleGroup.d.ts +37 -0
- package/dist/molecules/ToggleGroup.js +68 -0
- package/dist/molecules/Typography.d.ts +26 -0
- package/dist/molecules/Typography.js +67 -0
- package/dist/molecules/index.d.ts +37 -0
- package/dist/molecules/index.js +37 -0
- package/dist/organisms/AlertDialog.d.ts +40 -0
- package/dist/organisms/AlertDialog.js +104 -0
- package/dist/organisms/Calendar.d.ts +96 -0
- package/dist/organisms/Calendar.js +353 -0
- package/dist/organisms/Carousel.d.ts +29 -0
- package/dist/organisms/Carousel.js +128 -0
- package/dist/organisms/Chart.d.ts +55 -0
- package/dist/organisms/Chart.js +189 -0
- package/dist/organisms/Combobox.d.ts +41 -0
- package/dist/organisms/Combobox.js +102 -0
- package/dist/organisms/Command.d.ts +46 -0
- package/dist/organisms/Command.js +171 -0
- package/dist/organisms/CommandDialog.d.ts +42 -0
- package/dist/organisms/CommandDialog.js +116 -0
- package/dist/organisms/ContextMenu.d.ts +29 -0
- package/dist/organisms/ContextMenu.js +102 -0
- package/dist/organisms/DataTable.d.ts +50 -0
- package/dist/organisms/DataTable.js +257 -0
- package/dist/organisms/DatePicker.d.ts +34 -0
- package/dist/organisms/DatePicker.js +115 -0
- package/dist/organisms/DateRangePicker.d.ts +34 -0
- package/dist/organisms/DateRangePicker.js +122 -0
- package/dist/organisms/Dialog.d.ts +50 -0
- package/dist/organisms/Dialog.js +116 -0
- package/dist/organisms/Drawer.d.ts +40 -0
- package/dist/organisms/Drawer.js +131 -0
- package/dist/organisms/DropdownMenu.d.ts +30 -0
- package/dist/organisms/DropdownMenu.js +91 -0
- package/dist/organisms/Form.d.ts +114 -0
- package/dist/organisms/Form.js +151 -0
- package/dist/organisms/HoverCard.d.ts +30 -0
- package/dist/organisms/HoverCard.js +68 -0
- package/dist/organisms/Menubar.d.ts +28 -0
- package/dist/organisms/Menubar.js +147 -0
- package/dist/organisms/MessageScroller.d.ts +36 -0
- package/dist/organisms/MessageScroller.js +124 -0
- package/dist/organisms/NavigationMenu.d.ts +41 -0
- package/dist/organisms/NavigationMenu.js +139 -0
- package/dist/organisms/Popover.d.ts +33 -0
- package/dist/organisms/Popover.js +58 -0
- package/dist/organisms/Questionnaire.d.ts +74 -0
- package/dist/organisms/Questionnaire.js +235 -0
- package/dist/organisms/Select.d.ts +47 -0
- package/dist/organisms/Select.js +250 -0
- package/dist/organisms/Sheet.d.ts +34 -0
- package/dist/organisms/Sheet.js +107 -0
- package/dist/organisms/Sidebar.d.ts +100 -0
- package/dist/organisms/Sidebar.js +228 -0
- package/dist/organisms/Toaster.d.ts +67 -0
- package/dist/organisms/Toaster.js +189 -0
- package/dist/organisms/Tooltip.d.ts +39 -0
- package/dist/organisms/Tooltip.js +94 -0
- package/dist/organisms/index.d.ts +40 -0
- package/dist/organisms/index.js +40 -0
- package/dist/primitives/controllable.d.ts +38 -0
- package/dist/primitives/controllable.js +43 -0
- package/dist/primitives/dismissable.d.ts +53 -0
- package/dist/primitives/dismissable.js +158 -0
- package/dist/primitives/floating.d.ts +116 -0
- package/dist/primitives/floating.js +289 -0
- package/dist/primitives/floatingSurface.d.ts +56 -0
- package/dist/primitives/floatingSurface.js +142 -0
- package/dist/primitives/focusTrap.d.ts +47 -0
- package/dist/primitives/focusTrap.js +101 -0
- package/dist/primitives/focusable.d.ts +53 -0
- package/dist/primitives/focusable.js +109 -0
- package/dist/primitives/index.d.ts +27 -0
- package/dist/primitives/index.js +27 -0
- package/dist/primitives/menu.d.ts +102 -0
- package/dist/primitives/menu.js +356 -0
- package/dist/primitives/modalSurface.d.ts +54 -0
- package/dist/primitives/modalSurface.js +146 -0
- package/dist/primitives/portal.d.ts +34 -0
- package/dist/primitives/portal.js +42 -0
- package/dist/primitives/presence.d.ts +45 -0
- package/dist/primitives/presence.js +152 -0
- package/dist/primitives/rovingFocus.d.ts +47 -0
- package/dist/primitives/rovingFocus.js +176 -0
- package/dist/primitives/scrollLock.d.ts +21 -0
- package/dist/primitives/scrollLock.js +75 -0
- package/dist/primitives/typeahead.d.ts +39 -0
- package/dist/primitives/typeahead.js +123 -0
- package/dist/templates/AppShell.d.ts +30 -0
- package/dist/templates/AppShell.js +42 -0
- package/dist/templates/AuthLayout.d.ts +23 -0
- package/dist/templates/AuthLayout.js +42 -0
- package/dist/templates/SettingsLayout.d.ts +27 -0
- package/dist/templates/SettingsLayout.js +42 -0
- package/dist/templates/index.d.ts +16 -0
- package/dist/templates/index.js +16 -0
- package/nebula.css +2 -0
- package/package.json +92 -0
- package/registry.json +1107 -0
- package/src/adapters/css.ts +75 -0
- package/src/adapters/index.ts +40 -0
- package/src/adapters/tailwind.ts +134 -0
- package/src/adapters/types.ts +53 -0
- package/src/adapters/unocss.ts +116 -0
- package/src/atoms/AspectRatio.ts +28 -0
- package/src/atoms/Avatar.ts +67 -0
- package/src/atoms/Badge.ts +46 -0
- package/src/atoms/Button.ts +96 -0
- package/src/atoms/Checkbox.ts +76 -0
- package/src/atoms/Input.ts +76 -0
- package/src/atoms/Kbd.ts +28 -0
- package/src/atoms/Label.ts +38 -0
- package/src/atoms/Marker.ts +79 -0
- package/src/atoms/NativeSelect.ts +115 -0
- package/src/atoms/Progress.ts +64 -0
- package/src/atoms/ScrollArea.ts +53 -0
- package/src/atoms/Separator.ts +36 -0
- package/src/atoms/Skeleton.ts +24 -0
- package/src/atoms/Slider.ts +65 -0
- package/src/atoms/Spinner.ts +28 -0
- package/src/atoms/Switch.ts +56 -0
- package/src/atoms/Textarea.ts +58 -0
- package/src/atoms/Toggle.ts +67 -0
- package/src/atoms/index.ts +62 -0
- package/src/cli/add.ts +126 -0
- package/src/cli/index.ts +22 -0
- package/src/cli/registry.ts +116 -0
- package/src/config.ts +102 -0
- package/src/configure.ts +127 -0
- package/src/index.ts +53 -0
- package/src/lib/children.ts +43 -0
- package/src/lib/cn.ts +14 -0
- package/src/lib/cva.ts +249 -0
- package/src/lib/icons.ts +138 -0
- package/src/lib/id.ts +44 -0
- package/src/lib/index.ts +21 -0
- package/src/lib/motion.ts +48 -0
- package/src/lib/props.ts +83 -0
- package/src/lib/styled.ts +60 -0
- package/src/molecules/Accordion.ts +161 -0
- package/src/molecules/Alert.ts +58 -0
- package/src/molecules/Attachment.ts +132 -0
- package/src/molecules/Breadcrumb.ts +107 -0
- package/src/molecules/Bubble.ts +134 -0
- package/src/molecules/ButtonGroup.ts +55 -0
- package/src/molecules/Card.ts +42 -0
- package/src/molecules/Collapsible.ts +89 -0
- package/src/molecules/Empty.ts +43 -0
- package/src/molecules/Field.ts +131 -0
- package/src/molecules/InputGroup.ts +62 -0
- package/src/molecules/InputOTP.ts +166 -0
- package/src/molecules/Item.ts +89 -0
- package/src/molecules/Message.ts +127 -0
- package/src/molecules/Pagination.ts +197 -0
- package/src/molecules/RadioGroup.ts +104 -0
- package/src/molecules/Resizable.ts +166 -0
- package/src/molecules/Table.ts +118 -0
- package/src/molecules/Tabs.ts +148 -0
- package/src/molecules/ToggleGroup.ts +109 -0
- package/src/molecules/Typography.ts +94 -0
- package/src/molecules/index.ts +144 -0
- package/src/organisms/AlertDialog.ts +133 -0
- package/src/organisms/Calendar.ts +470 -0
- package/src/organisms/Carousel.ts +161 -0
- package/src/organisms/Chart.ts +257 -0
- package/src/organisms/Combobox.ts +140 -0
- package/src/organisms/Command.ts +211 -0
- package/src/organisms/CommandDialog.ts +149 -0
- package/src/organisms/ContextMenu.ts +125 -0
- package/src/organisms/DataTable.ts +328 -0
- package/src/organisms/DatePicker.ts +141 -0
- package/src/organisms/DateRangePicker.ts +149 -0
- package/src/organisms/Dialog.ts +162 -0
- package/src/organisms/Drawer.ts +167 -0
- package/src/organisms/DropdownMenu.ts +112 -0
- package/src/organisms/Form.ts +217 -0
- package/src/organisms/HoverCard.ts +92 -0
- package/src/organisms/Menubar.ts +167 -0
- package/src/organisms/MessageScroller.ts +145 -0
- package/src/organisms/NavigationMenu.ts +184 -0
- package/src/organisms/Popover.ts +84 -0
- package/src/organisms/Questionnaire.ts +322 -0
- package/src/organisms/Select.ts +286 -0
- package/src/organisms/Sheet.ts +144 -0
- package/src/organisms/Sidebar.ts +336 -0
- package/src/organisms/Toaster.ts +246 -0
- package/src/organisms/Tooltip.ts +124 -0
- package/src/organisms/index.ts +134 -0
- package/src/primitives/controllable.ts +64 -0
- package/src/primitives/dismissable.ts +195 -0
- package/src/primitives/floating.ts +453 -0
- package/src/primitives/floatingSurface.ts +209 -0
- package/src/primitives/focusTrap.ts +132 -0
- package/src/primitives/focusable.ts +108 -0
- package/src/primitives/index.ts +88 -0
- package/src/primitives/menu.ts +491 -0
- package/src/primitives/modalSurface.ts +199 -0
- package/src/primitives/portal.ts +61 -0
- package/src/primitives/presence.ts +185 -0
- package/src/primitives/rovingFocus.ts +212 -0
- package/src/primitives/scrollLock.ts +79 -0
- package/src/primitives/typeahead.ts +156 -0
- package/src/templates/AppShell.ts +56 -0
- package/src/templates/AuthLayout.ts +66 -0
- package/src/templates/SettingsLayout.ts +63 -0
- package/src/templates/index.ts +21 -0
- 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
|
+
});
|