@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,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
|
+
});
|