@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,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MessageScroller — a conversation that stays pinned to the newest message.
|
|
3
|
+
*
|
|
4
|
+
* The whole component is one rule and its exception: **follow the bottom, but
|
|
5
|
+
* never yank the reader.**
|
|
6
|
+
*
|
|
7
|
+
* A chat log that always scrolls to the end is unusable the moment you scroll
|
|
8
|
+
* up to re-read something and a new message arrives. One that never scrolls is
|
|
9
|
+
* unusable in the ordinary case. So the container remembers whether the reader
|
|
10
|
+
* was already at the bottom when the content grew: if they were, it follows; if
|
|
11
|
+
* they had scrolled away, it holds position and offers a button instead.
|
|
12
|
+
*
|
|
13
|
+
* Growth is detected with a `ResizeObserver` on the inner wrapper rather than
|
|
14
|
+
* by counting children. An image finishing its load, a code block wrapping, a
|
|
15
|
+
* streamed reply growing token by token — none of those change the child count,
|
|
16
|
+
* and all of them move the bottom.
|
|
17
|
+
*
|
|
18
|
+
* `atBottom` carries a tolerance because a scroll position is fractional on a
|
|
19
|
+
* scaled display: `scrollTop + clientHeight` lands a fraction short of
|
|
20
|
+
* `scrollHeight` at the true bottom, and an exact comparison would decide the
|
|
21
|
+
* reader had scrolled away when they had not.
|
|
22
|
+
*
|
|
23
|
+
* `role="log"` with `aria-live="polite"`: new turns are announced as they
|
|
24
|
+
* arrive, without interrupting whatever is being read.
|
|
25
|
+
*/
|
|
26
|
+
import { component, html, onMount, onUnmount, signal } from "@c9up/aurora";
|
|
27
|
+
import { buttonVariants } from "../atoms/Button.js";
|
|
28
|
+
import { slot } from "../lib/children.js";
|
|
29
|
+
import { cn } from "../lib/cn.js";
|
|
30
|
+
import { ChevronDownIcon } from "../lib/icons.js";
|
|
31
|
+
import { uid } from "../lib/id.js";
|
|
32
|
+
import { read } from "../lib/props.js";
|
|
33
|
+
/** How far from the true bottom still counts as "at the bottom", in px. */
|
|
34
|
+
const BOTTOM_TOLERANCE = 24;
|
|
35
|
+
export const MessageScroller = component((props) => {
|
|
36
|
+
const viewportId = uid("message-scroller");
|
|
37
|
+
const contentId = uid("message-scroller-content");
|
|
38
|
+
const pinned = signal(true);
|
|
39
|
+
function viewport() {
|
|
40
|
+
return document.getElementById(viewportId);
|
|
41
|
+
}
|
|
42
|
+
function isAtBottom(element) {
|
|
43
|
+
const distance = element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
44
|
+
return distance <= BOTTOM_TOLERANCE;
|
|
45
|
+
}
|
|
46
|
+
function scrollToBottom(smooth) {
|
|
47
|
+
const element = viewport();
|
|
48
|
+
if (element === null)
|
|
49
|
+
return;
|
|
50
|
+
element.scrollTo({
|
|
51
|
+
top: element.scrollHeight,
|
|
52
|
+
behavior: smooth ? "smooth" : "auto",
|
|
53
|
+
});
|
|
54
|
+
pinned(true);
|
|
55
|
+
}
|
|
56
|
+
function onScroll() {
|
|
57
|
+
const element = viewport();
|
|
58
|
+
if (element === null)
|
|
59
|
+
return;
|
|
60
|
+
pinned(isAtBottom(element));
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Follow the bottom only if we were already there.
|
|
64
|
+
*
|
|
65
|
+
* Read from the `pinned` signal rather than re-measuring: by the time the
|
|
66
|
+
* observer fires, the content has already grown and the viewport is no
|
|
67
|
+
* longer at the bottom by definition. The answer has to come from before.
|
|
68
|
+
*/
|
|
69
|
+
function onContentResize() {
|
|
70
|
+
if (!pinned())
|
|
71
|
+
return;
|
|
72
|
+
scrollToBottom(false);
|
|
73
|
+
}
|
|
74
|
+
let observer;
|
|
75
|
+
onMount(() => {
|
|
76
|
+
const element = viewport();
|
|
77
|
+
if (element === null)
|
|
78
|
+
return;
|
|
79
|
+
element.addEventListener("scroll", onScroll, { passive: true });
|
|
80
|
+
// Start at the newest message, without animating there.
|
|
81
|
+
scrollToBottom(false);
|
|
82
|
+
if (typeof ResizeObserver !== "function")
|
|
83
|
+
return;
|
|
84
|
+
const content = document.getElementById(contentId);
|
|
85
|
+
if (content === null)
|
|
86
|
+
return;
|
|
87
|
+
observer = new ResizeObserver(onContentResize);
|
|
88
|
+
observer.observe(content);
|
|
89
|
+
});
|
|
90
|
+
onUnmount(() => {
|
|
91
|
+
viewport()?.removeEventListener("scroll", onScroll);
|
|
92
|
+
observer?.disconnect();
|
|
93
|
+
});
|
|
94
|
+
return html `<div
|
|
95
|
+
data-slot="message-scroller"
|
|
96
|
+
class="${() => cn("relative flex min-h-0 flex-col", read(props.class))}"
|
|
97
|
+
>
|
|
98
|
+
<div
|
|
99
|
+
data-slot="message-scroller-viewport"
|
|
100
|
+
id="${viewportId}"
|
|
101
|
+
role="log"
|
|
102
|
+
aria-live="polite"
|
|
103
|
+
aria-label="${props.label ?? "Conversation"}"
|
|
104
|
+
tabindex="0"
|
|
105
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none [scrollbar-width:thin]"
|
|
106
|
+
>
|
|
107
|
+
<div
|
|
108
|
+
data-slot="message-scroller-content"
|
|
109
|
+
id="${contentId}"
|
|
110
|
+
class="flex flex-col gap-4 p-4"
|
|
111
|
+
>${slot(props.children)}</div>
|
|
112
|
+
</div>
|
|
113
|
+
<button
|
|
114
|
+
type="button"
|
|
115
|
+
data-slot="message-scroller-jump"
|
|
116
|
+
?hidden="${() => pinned()}"
|
|
117
|
+
class="${cn(buttonVariants({ variant: "secondary", size: "sm" }), "absolute inset-x-0 bottom-3 mx-auto w-fit gap-1 rounded-full shadow-md")}"
|
|
118
|
+
@click="${() => scrollToBottom(true)}"
|
|
119
|
+
>
|
|
120
|
+
${ChevronDownIcon({ class: "size-4" })}
|
|
121
|
+
${props.jumpLabel ?? "Jump to latest"}
|
|
122
|
+
</button>
|
|
123
|
+
</div>`;
|
|
124
|
+
});
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationMenu — a site navigation bar with drop-down panels.
|
|
3
|
+
*
|
|
4
|
+
* Not a menu, despite the name Radix gave it, and the distinction changes the
|
|
5
|
+
* markup. A menu is a list of *commands*; this is a list of *links*. So it is
|
|
6
|
+
* a `<nav>` containing a list of anchors, with `aria-expanded` on the entries
|
|
7
|
+
* that open a panel — never `role="menu"`, which would have a screen reader
|
|
8
|
+
* announce a navigation bar as an application menu and offer the wrong
|
|
9
|
+
* shortcuts for it.
|
|
10
|
+
*
|
|
11
|
+
* Entries are either a plain link or a link with a panel. Hover opens a panel
|
|
12
|
+
* after a short delay and keeps it open briefly on leave, because the pointer
|
|
13
|
+
* has to travel from the trigger to the panel and a naive `mouseleave` closes
|
|
14
|
+
* it in the gap between them.
|
|
15
|
+
*
|
|
16
|
+
* Every panel is reachable by keyboard: the trigger is a real button, Enter
|
|
17
|
+
* and ArrowDown open it, Escape closes it and returns focus.
|
|
18
|
+
*/
|
|
19
|
+
import type { Child } from "../lib/children.js";
|
|
20
|
+
import { type Slot } from "../lib/children.js";
|
|
21
|
+
import { type Reactive } from "../lib/props.js";
|
|
22
|
+
export interface NavigationLink {
|
|
23
|
+
label: Child;
|
|
24
|
+
href: string;
|
|
25
|
+
description?: Child;
|
|
26
|
+
}
|
|
27
|
+
export interface NavigationItem {
|
|
28
|
+
label: Child;
|
|
29
|
+
/** A plain link. Mutually exclusive with `links` / `content`. */
|
|
30
|
+
href?: string;
|
|
31
|
+
/** A panel of links, laid out in a grid. */
|
|
32
|
+
links?: readonly NavigationLink[];
|
|
33
|
+
/** Arbitrary panel content, when a link grid is not enough. */
|
|
34
|
+
content?: Slot;
|
|
35
|
+
}
|
|
36
|
+
export interface NavigationMenuProps {
|
|
37
|
+
items: readonly NavigationItem[];
|
|
38
|
+
class?: Reactive<string>;
|
|
39
|
+
}
|
|
40
|
+
export declare const triggerClasses = "group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50";
|
|
41
|
+
export declare const NavigationMenu: (props?: NavigationMenuProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* NavigationMenu — a site navigation bar with drop-down panels.
|
|
3
|
+
*
|
|
4
|
+
* Not a menu, despite the name Radix gave it, and the distinction changes the
|
|
5
|
+
* markup. A menu is a list of *commands*; this is a list of *links*. So it is
|
|
6
|
+
* a `<nav>` containing a list of anchors, with `aria-expanded` on the entries
|
|
7
|
+
* that open a panel — never `role="menu"`, which would have a screen reader
|
|
8
|
+
* announce a navigation bar as an application menu and offer the wrong
|
|
9
|
+
* shortcuts for it.
|
|
10
|
+
*
|
|
11
|
+
* Entries are either a plain link or a link with a panel. Hover opens a panel
|
|
12
|
+
* after a short delay and keeps it open briefly on leave, because the pointer
|
|
13
|
+
* has to travel from the trigger to the panel and a naive `mouseleave` closes
|
|
14
|
+
* it in the gap between them.
|
|
15
|
+
*
|
|
16
|
+
* Every panel is reachable by keyboard: the trigger is a real button, Enter
|
|
17
|
+
* and ArrowDown open it, Escape closes it and returns focus.
|
|
18
|
+
*/
|
|
19
|
+
import { component, html, onUnmount, signal } from "@c9up/aurora";
|
|
20
|
+
import { slot } from "../lib/children.js";
|
|
21
|
+
import { cn } from "../lib/cn.js";
|
|
22
|
+
import { ChevronDownIcon } from "../lib/icons.js";
|
|
23
|
+
import { uid } from "../lib/id.js";
|
|
24
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
25
|
+
import { read } from "../lib/props.js";
|
|
26
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
27
|
+
const OPEN_DELAY_MS = 150;
|
|
28
|
+
const CLOSE_DELAY_MS = 250;
|
|
29
|
+
export const triggerClasses = "group inline-flex h-9 w-max items-center justify-center gap-1 rounded-md bg-background px-4 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50 data-[state=open]:bg-accent/50";
|
|
30
|
+
export const NavigationMenu = component((props) => {
|
|
31
|
+
const contentId = uid("navigation-menu-content");
|
|
32
|
+
const triggerIds = props.items.map(() => uid("navigation-menu-trigger"));
|
|
33
|
+
const openIndex = signal(-1);
|
|
34
|
+
let timer;
|
|
35
|
+
function clear() {
|
|
36
|
+
if (timer !== undefined)
|
|
37
|
+
clearTimeout(timer);
|
|
38
|
+
timer = undefined;
|
|
39
|
+
}
|
|
40
|
+
function schedule(index, delay) {
|
|
41
|
+
clear();
|
|
42
|
+
timer = setTimeout(() => openIndex(index), delay);
|
|
43
|
+
}
|
|
44
|
+
onUnmount(clear);
|
|
45
|
+
function close() {
|
|
46
|
+
clear();
|
|
47
|
+
openIndex(-1);
|
|
48
|
+
}
|
|
49
|
+
floatingSurface({
|
|
50
|
+
anchor: () => {
|
|
51
|
+
const id = triggerIds[openIndex()];
|
|
52
|
+
return id === undefined ? null : document.getElementById(id);
|
|
53
|
+
},
|
|
54
|
+
open: () => openIndex() !== -1,
|
|
55
|
+
onClose: close,
|
|
56
|
+
placement: "bottom-start",
|
|
57
|
+
offset: 6,
|
|
58
|
+
content: () => html `<div
|
|
59
|
+
data-slot="navigation-menu-content"
|
|
60
|
+
id="${contentId}"
|
|
61
|
+
class="${cn("bg-popover text-popover-foreground z-50 rounded-md border p-4 shadow-md outline-none", zoomInOut)}"
|
|
62
|
+
@pointerenter="${clear}"
|
|
63
|
+
@pointerleave="${() => schedule(-1, CLOSE_DELAY_MS)}"
|
|
64
|
+
>${renderPanel(props.items[openIndex()])}</div>`,
|
|
65
|
+
});
|
|
66
|
+
return html `<nav
|
|
67
|
+
data-slot="navigation-menu"
|
|
68
|
+
class="${() => cn("relative flex max-w-max flex-1 items-center justify-center", read(props.class))}"
|
|
69
|
+
>
|
|
70
|
+
<ul class="flex flex-1 list-none items-center justify-center gap-1">
|
|
71
|
+
${props.items.map((item, index) => renderEntry(item, index))}
|
|
72
|
+
</ul>
|
|
73
|
+
</nav>`;
|
|
74
|
+
function renderEntry(item, index) {
|
|
75
|
+
if (item.href !== undefined) {
|
|
76
|
+
return html `<li>
|
|
77
|
+
<a
|
|
78
|
+
href="${item.href}"
|
|
79
|
+
data-slot="navigation-menu-link"
|
|
80
|
+
class="${triggerClasses}"
|
|
81
|
+
@pointerenter="${() => {
|
|
82
|
+
// Hovering a plain link while a panel is open closes it: the
|
|
83
|
+
// pointer has left the panel's branch of the bar.
|
|
84
|
+
if (openIndex() !== -1)
|
|
85
|
+
schedule(-1, CLOSE_DELAY_MS);
|
|
86
|
+
}}"
|
|
87
|
+
>${item.label}</a>
|
|
88
|
+
</li>`;
|
|
89
|
+
}
|
|
90
|
+
return html `<li>
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
id="${triggerIds[index]}"
|
|
94
|
+
data-slot="navigation-menu-trigger"
|
|
95
|
+
aria-expanded="${() => (openIndex() === index ? "true" : "false")}"
|
|
96
|
+
aria-controls="${() => (openIndex() === index ? contentId : undefined)}"
|
|
97
|
+
data-state="${() => (openIndex() === index ? "open" : "closed")}"
|
|
98
|
+
class="${triggerClasses}"
|
|
99
|
+
@click="${() => (openIndex() === index ? close() : openIndex(index))}"
|
|
100
|
+
@pointerenter="${() => schedule(index, OPEN_DELAY_MS)}"
|
|
101
|
+
@pointerleave="${() => schedule(-1, CLOSE_DELAY_MS)}"
|
|
102
|
+
@keydown="${(event) => {
|
|
103
|
+
if (event.key === "ArrowDown" ||
|
|
104
|
+
event.key === "Enter" ||
|
|
105
|
+
event.key === " ") {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
openIndex(index);
|
|
108
|
+
}
|
|
109
|
+
}}"
|
|
110
|
+
>
|
|
111
|
+
${item.label}
|
|
112
|
+
${ChevronDownIcon({
|
|
113
|
+
class: "relative top-px size-3 transition-transform duration-200 group-data-[state=open]:rotate-180",
|
|
114
|
+
})}
|
|
115
|
+
</button>
|
|
116
|
+
</li>`;
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
function renderPanel(item) {
|
|
120
|
+
if (item === undefined)
|
|
121
|
+
return null;
|
|
122
|
+
if (item.content !== undefined)
|
|
123
|
+
return slot(item.content)();
|
|
124
|
+
return html `<ul class="grid w-[400px] gap-1 md:w-[500px] md:grid-cols-2">
|
|
125
|
+
${(item.links ?? []).map((link) => html `<li>
|
|
126
|
+
<a
|
|
127
|
+
href="${link.href}"
|
|
128
|
+
class="hover:bg-accent hover:text-accent-foreground focus:bg-accent block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors"
|
|
129
|
+
>
|
|
130
|
+
<div class="text-sm leading-none font-medium">${link.label}</div>
|
|
131
|
+
${link.description === undefined
|
|
132
|
+
? null
|
|
133
|
+
: html `<p class="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
|
134
|
+
${link.description}
|
|
135
|
+
</p>`}
|
|
136
|
+
</a>
|
|
137
|
+
</li>`)}
|
|
138
|
+
</ul>`;
|
|
139
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover — a floating panel anchored to a trigger.
|
|
3
|
+
*
|
|
4
|
+
* The plainest use of `floatingSurface`, and the one to read first: the other
|
|
5
|
+
* anchored overlays in this directory are this component with a different
|
|
6
|
+
* opening gesture and different contents.
|
|
7
|
+
*
|
|
8
|
+
* `modal` decides whether focus is trapped. A popover holding a form should
|
|
9
|
+
* trap — tabbing out of a half-filled form into the page behind loses the
|
|
10
|
+
* user's place. A popover holding a paragraph of help should not, because
|
|
11
|
+
* trapping focus in something the user can only read is a dead end.
|
|
12
|
+
*/
|
|
13
|
+
import { type Slot } from "../lib/children.js";
|
|
14
|
+
import { type Reactive } from "../lib/props.js";
|
|
15
|
+
import type { Placement } from "../primitives/floating.js";
|
|
16
|
+
export declare const popoverContentClasses = "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none";
|
|
17
|
+
export interface PopoverProps {
|
|
18
|
+
/** Rendered inside the trigger button. */
|
|
19
|
+
trigger?: Slot;
|
|
20
|
+
/** The panel's contents. */
|
|
21
|
+
children?: Slot;
|
|
22
|
+
open?: Reactive<boolean>;
|
|
23
|
+
defaultOpen?: boolean;
|
|
24
|
+
onOpenChange?: (open: boolean) => void;
|
|
25
|
+
placement?: Placement;
|
|
26
|
+
offset?: number;
|
|
27
|
+
/** Trap focus inside. Use for panels holding form controls. */
|
|
28
|
+
modal?: boolean;
|
|
29
|
+
class?: Reactive<string>;
|
|
30
|
+
triggerClass?: Reactive<string>;
|
|
31
|
+
contentClass?: Reactive<string>;
|
|
32
|
+
}
|
|
33
|
+
export declare const Popover: (props?: PopoverProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Popover — a floating panel anchored to a trigger.
|
|
3
|
+
*
|
|
4
|
+
* The plainest use of `floatingSurface`, and the one to read first: the other
|
|
5
|
+
* anchored overlays in this directory are this component with a different
|
|
6
|
+
* opening gesture and different contents.
|
|
7
|
+
*
|
|
8
|
+
* `modal` decides whether focus is trapped. A popover holding a form should
|
|
9
|
+
* trap — tabbing out of a half-filled form into the page behind loses the
|
|
10
|
+
* user's place. A popover holding a paragraph of help should not, because
|
|
11
|
+
* trapping focus in something the user can only read is a dead end.
|
|
12
|
+
*/
|
|
13
|
+
import { component, html } from "@c9up/aurora";
|
|
14
|
+
import { slot } from "../lib/children.js";
|
|
15
|
+
import { cn } from "../lib/cn.js";
|
|
16
|
+
import { uid } from "../lib/id.js";
|
|
17
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
18
|
+
import { read } from "../lib/props.js";
|
|
19
|
+
import { controllable } from "../primitives/controllable.js";
|
|
20
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
21
|
+
export const popoverContentClasses = "bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none";
|
|
22
|
+
export const Popover = component((props) => {
|
|
23
|
+
const triggerId = uid("popover-trigger");
|
|
24
|
+
const contentId = uid("popover-content");
|
|
25
|
+
const state = controllable({
|
|
26
|
+
value: props.open,
|
|
27
|
+
initial: props.defaultOpen ?? false,
|
|
28
|
+
onChange: props.onOpenChange,
|
|
29
|
+
});
|
|
30
|
+
floatingSurface({
|
|
31
|
+
anchor: () => document.getElementById(triggerId),
|
|
32
|
+
open: () => state.current(),
|
|
33
|
+
onClose: () => state.set(false),
|
|
34
|
+
placement: props.placement ?? "bottom",
|
|
35
|
+
offset: props.offset ?? 4,
|
|
36
|
+
trapFocus: props.modal === true,
|
|
37
|
+
autoFocus: props.modal !== true,
|
|
38
|
+
content: () => html `<div
|
|
39
|
+
data-slot="popover-content"
|
|
40
|
+
id="${contentId}"
|
|
41
|
+
role="dialog"
|
|
42
|
+
aria-labelledby="${triggerId}"
|
|
43
|
+
tabindex="-1"
|
|
44
|
+
class="${cn(popoverContentClasses, zoomInOut, read(props.contentClass))}"
|
|
45
|
+
>${slot(props.children)}</div>`,
|
|
46
|
+
});
|
|
47
|
+
return html `<button
|
|
48
|
+
type="button"
|
|
49
|
+
data-slot="popover-trigger"
|
|
50
|
+
id="${triggerId}"
|
|
51
|
+
aria-haspopup="dialog"
|
|
52
|
+
aria-expanded="${() => (state.current() ? "true" : "false")}"
|
|
53
|
+
aria-controls="${() => (state.current() ? contentId : undefined)}"
|
|
54
|
+
data-state="${() => (state.current() ? "open" : "closed")}"
|
|
55
|
+
class="${() => cn(read(props.triggerClass))}"
|
|
56
|
+
@click="${() => state.set(!state.current())}"
|
|
57
|
+
>${slot(props.trigger)}</button>`;
|
|
58
|
+
});
|
|
@@ -0,0 +1,74 @@
|
|
|
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 type { Child } from "../lib/children.js";
|
|
26
|
+
import { type Reactive } from "../lib/props.js";
|
|
27
|
+
export interface QuestionOption {
|
|
28
|
+
value: string;
|
|
29
|
+
label: Child;
|
|
30
|
+
description?: Child;
|
|
31
|
+
}
|
|
32
|
+
interface BaseQuestion {
|
|
33
|
+
/** Stable key. The answer is filed under it. */
|
|
34
|
+
id: string;
|
|
35
|
+
prompt: Child;
|
|
36
|
+
description?: Child;
|
|
37
|
+
/** Blocks Next until answered. */
|
|
38
|
+
required?: boolean;
|
|
39
|
+
}
|
|
40
|
+
export interface SingleChoiceQuestion extends BaseQuestion {
|
|
41
|
+
type: "single";
|
|
42
|
+
options: readonly QuestionOption[];
|
|
43
|
+
}
|
|
44
|
+
export interface MultipleChoiceQuestion extends BaseQuestion {
|
|
45
|
+
type: "multiple";
|
|
46
|
+
options: readonly QuestionOption[];
|
|
47
|
+
}
|
|
48
|
+
export interface FreeformQuestion extends BaseQuestion {
|
|
49
|
+
type: "text";
|
|
50
|
+
placeholder?: string;
|
|
51
|
+
/** Renders a textarea instead of a single-line input. */
|
|
52
|
+
multiline?: boolean;
|
|
53
|
+
}
|
|
54
|
+
export type Question = SingleChoiceQuestion | MultipleChoiceQuestion | FreeformQuestion;
|
|
55
|
+
/** One answer: a value for single/text, a set for multiple. */
|
|
56
|
+
export type Answer = string | readonly string[];
|
|
57
|
+
export type Answers = Readonly<Record<string, Answer>>;
|
|
58
|
+
export interface QuestionnaireProps {
|
|
59
|
+
questions: readonly Question[];
|
|
60
|
+
onComplete: (answers: Answers) => void;
|
|
61
|
+
onAnswerChange?: (id: string, answer: Answer) => void;
|
|
62
|
+
/** Allow skipping questions that are not required. Default `true`. */
|
|
63
|
+
skippable?: boolean;
|
|
64
|
+
showProgress?: boolean;
|
|
65
|
+
backLabel?: string;
|
|
66
|
+
nextLabel?: string;
|
|
67
|
+
skipLabel?: string;
|
|
68
|
+
submitLabel?: string;
|
|
69
|
+
class?: Reactive<string>;
|
|
70
|
+
}
|
|
71
|
+
/** Has this question been answered at all? */
|
|
72
|
+
export declare function isAnswered(question: Question, answer: Answer | undefined): boolean;
|
|
73
|
+
export declare const Questionnaire: (props?: QuestionnaireProps | undefined) => import("@c9up/aurora").TemplateResult;
|
|
74
|
+
export {};
|