@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,145 @@
|
|
|
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
|
+
|
|
27
|
+
import { component, html, onMount, onUnmount, signal } from "@c9up/aurora";
|
|
28
|
+
import { buttonVariants } from "../atoms/Button.js";
|
|
29
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
30
|
+
import { cn } from "../lib/cn.js";
|
|
31
|
+
import { ChevronDownIcon } from "../lib/icons.js";
|
|
32
|
+
import { uid } from "../lib/id.js";
|
|
33
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
34
|
+
|
|
35
|
+
/** How far from the true bottom still counts as "at the bottom", in px. */
|
|
36
|
+
const BOTTOM_TOLERANCE = 24;
|
|
37
|
+
|
|
38
|
+
export interface MessageScrollerProps {
|
|
39
|
+
children?: Slot;
|
|
40
|
+
/** Announced as the log's name. */
|
|
41
|
+
label?: string;
|
|
42
|
+
/** Label for the catch-up button. Default `"Jump to latest"`. */
|
|
43
|
+
jumpLabel?: string;
|
|
44
|
+
class?: Reactive<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const MessageScroller = component<MessageScrollerProps>((props) => {
|
|
48
|
+
const viewportId = uid("message-scroller");
|
|
49
|
+
const contentId = uid("message-scroller-content");
|
|
50
|
+
const pinned = signal(true);
|
|
51
|
+
|
|
52
|
+
function viewport(): HTMLElement | null {
|
|
53
|
+
return document.getElementById(viewportId);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAtBottom(element: HTMLElement): boolean {
|
|
57
|
+
const distance =
|
|
58
|
+
element.scrollHeight - element.scrollTop - element.clientHeight;
|
|
59
|
+
return distance <= BOTTOM_TOLERANCE;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function scrollToBottom(smooth: boolean): void {
|
|
63
|
+
const element = viewport();
|
|
64
|
+
if (element === null) return;
|
|
65
|
+
element.scrollTo({
|
|
66
|
+
top: element.scrollHeight,
|
|
67
|
+
behavior: smooth ? "smooth" : "auto",
|
|
68
|
+
});
|
|
69
|
+
pinned(true);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function onScroll(): void {
|
|
73
|
+
const element = viewport();
|
|
74
|
+
if (element === null) return;
|
|
75
|
+
pinned(isAtBottom(element));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Follow the bottom only if we were already there.
|
|
80
|
+
*
|
|
81
|
+
* Read from the `pinned` signal rather than re-measuring: by the time the
|
|
82
|
+
* observer fires, the content has already grown and the viewport is no
|
|
83
|
+
* longer at the bottom by definition. The answer has to come from before.
|
|
84
|
+
*/
|
|
85
|
+
function onContentResize(): void {
|
|
86
|
+
if (!pinned()) return;
|
|
87
|
+
scrollToBottom(false);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let observer: ResizeObserver | undefined;
|
|
91
|
+
|
|
92
|
+
onMount(() => {
|
|
93
|
+
const element = viewport();
|
|
94
|
+
if (element === null) return;
|
|
95
|
+
|
|
96
|
+
element.addEventListener("scroll", onScroll, { passive: true });
|
|
97
|
+
// Start at the newest message, without animating there.
|
|
98
|
+
scrollToBottom(false);
|
|
99
|
+
|
|
100
|
+
if (typeof ResizeObserver !== "function") return;
|
|
101
|
+
const content = document.getElementById(contentId);
|
|
102
|
+
if (content === null) return;
|
|
103
|
+
observer = new ResizeObserver(onContentResize);
|
|
104
|
+
observer.observe(content);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
onUnmount(() => {
|
|
108
|
+
viewport()?.removeEventListener("scroll", onScroll);
|
|
109
|
+
observer?.disconnect();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
return html`<div
|
|
113
|
+
data-slot="message-scroller"
|
|
114
|
+
class="${() => cn("relative flex min-h-0 flex-col", read(props.class))}"
|
|
115
|
+
>
|
|
116
|
+
<div
|
|
117
|
+
data-slot="message-scroller-viewport"
|
|
118
|
+
id="${viewportId}"
|
|
119
|
+
role="log"
|
|
120
|
+
aria-live="polite"
|
|
121
|
+
aria-label="${props.label ?? "Conversation"}"
|
|
122
|
+
tabindex="0"
|
|
123
|
+
class="min-h-0 flex-1 overflow-y-auto overscroll-contain outline-none [scrollbar-width:thin]"
|
|
124
|
+
>
|
|
125
|
+
<div
|
|
126
|
+
data-slot="message-scroller-content"
|
|
127
|
+
id="${contentId}"
|
|
128
|
+
class="flex flex-col gap-4 p-4"
|
|
129
|
+
>${slot(props.children)}</div>
|
|
130
|
+
</div>
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
data-slot="message-scroller-jump"
|
|
134
|
+
?hidden="${() => pinned()}"
|
|
135
|
+
class="${cn(
|
|
136
|
+
buttonVariants({ variant: "secondary", size: "sm" }),
|
|
137
|
+
"absolute inset-x-0 bottom-3 mx-auto w-fit gap-1 rounded-full shadow-md",
|
|
138
|
+
)}"
|
|
139
|
+
@click="${() => scrollToBottom(true)}"
|
|
140
|
+
>
|
|
141
|
+
${ChevronDownIcon({ class: "size-4" })}
|
|
142
|
+
${props.jumpLabel ?? "Jump to latest"}
|
|
143
|
+
</button>
|
|
144
|
+
</div>`;
|
|
145
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
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
|
+
|
|
20
|
+
import { component, html, onUnmount, signal } from "@c9up/aurora";
|
|
21
|
+
import type { Child } from "../lib/children.js";
|
|
22
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
23
|
+
import { cn } from "../lib/cn.js";
|
|
24
|
+
import { ChevronDownIcon } from "../lib/icons.js";
|
|
25
|
+
import { uid } from "../lib/id.js";
|
|
26
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
27
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
28
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
29
|
+
|
|
30
|
+
const OPEN_DELAY_MS = 150;
|
|
31
|
+
const CLOSE_DELAY_MS = 250;
|
|
32
|
+
|
|
33
|
+
export interface NavigationLink {
|
|
34
|
+
label: Child;
|
|
35
|
+
href: string;
|
|
36
|
+
description?: Child;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface NavigationItem {
|
|
40
|
+
label: Child;
|
|
41
|
+
/** A plain link. Mutually exclusive with `links` / `content`. */
|
|
42
|
+
href?: string;
|
|
43
|
+
/** A panel of links, laid out in a grid. */
|
|
44
|
+
links?: readonly NavigationLink[];
|
|
45
|
+
/** Arbitrary panel content, when a link grid is not enough. */
|
|
46
|
+
content?: Slot;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface NavigationMenuProps {
|
|
50
|
+
items: readonly NavigationItem[];
|
|
51
|
+
class?: Reactive<string>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export const triggerClasses =
|
|
55
|
+
"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";
|
|
56
|
+
|
|
57
|
+
export const NavigationMenu = component<NavigationMenuProps>((props) => {
|
|
58
|
+
const contentId = uid("navigation-menu-content");
|
|
59
|
+
const triggerIds = props.items.map(() => uid("navigation-menu-trigger"));
|
|
60
|
+
const openIndex = signal(-1);
|
|
61
|
+
|
|
62
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
63
|
+
|
|
64
|
+
function clear(): void {
|
|
65
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
66
|
+
timer = undefined;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function schedule(index: number, delay: number): void {
|
|
70
|
+
clear();
|
|
71
|
+
timer = setTimeout(() => openIndex(index), delay);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
onUnmount(clear);
|
|
75
|
+
|
|
76
|
+
function close(): void {
|
|
77
|
+
clear();
|
|
78
|
+
openIndex(-1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
floatingSurface({
|
|
82
|
+
anchor: () => {
|
|
83
|
+
const id = triggerIds[openIndex()];
|
|
84
|
+
return id === undefined ? null : document.getElementById(id);
|
|
85
|
+
},
|
|
86
|
+
open: () => openIndex() !== -1,
|
|
87
|
+
onClose: close,
|
|
88
|
+
placement: "bottom-start",
|
|
89
|
+
offset: 6,
|
|
90
|
+
content: () =>
|
|
91
|
+
html`<div
|
|
92
|
+
data-slot="navigation-menu-content"
|
|
93
|
+
id="${contentId}"
|
|
94
|
+
class="${cn(
|
|
95
|
+
"bg-popover text-popover-foreground z-50 rounded-md border p-4 shadow-md outline-none",
|
|
96
|
+
zoomInOut,
|
|
97
|
+
)}"
|
|
98
|
+
@pointerenter="${clear}"
|
|
99
|
+
@pointerleave="${() => schedule(-1, CLOSE_DELAY_MS)}"
|
|
100
|
+
>${renderPanel(props.items[openIndex()])}</div>`,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
return html`<nav
|
|
104
|
+
data-slot="navigation-menu"
|
|
105
|
+
class="${() => cn("relative flex max-w-max flex-1 items-center justify-center", read(props.class))}"
|
|
106
|
+
>
|
|
107
|
+
<ul class="flex flex-1 list-none items-center justify-center gap-1">
|
|
108
|
+
${props.items.map((item, index) => renderEntry(item, index))}
|
|
109
|
+
</ul>
|
|
110
|
+
</nav>`;
|
|
111
|
+
|
|
112
|
+
function renderEntry(item: NavigationItem, index: number): Child {
|
|
113
|
+
if (item.href !== undefined) {
|
|
114
|
+
return html`<li>
|
|
115
|
+
<a
|
|
116
|
+
href="${item.href}"
|
|
117
|
+
data-slot="navigation-menu-link"
|
|
118
|
+
class="${triggerClasses}"
|
|
119
|
+
@pointerenter="${() => {
|
|
120
|
+
// Hovering a plain link while a panel is open closes it: the
|
|
121
|
+
// pointer has left the panel's branch of the bar.
|
|
122
|
+
if (openIndex() !== -1) schedule(-1, CLOSE_DELAY_MS);
|
|
123
|
+
}}"
|
|
124
|
+
>${item.label}</a>
|
|
125
|
+
</li>`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
return html`<li>
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
id="${triggerIds[index]}"
|
|
132
|
+
data-slot="navigation-menu-trigger"
|
|
133
|
+
aria-expanded="${() => (openIndex() === index ? "true" : "false")}"
|
|
134
|
+
aria-controls="${() => (openIndex() === index ? contentId : undefined)}"
|
|
135
|
+
data-state="${() => (openIndex() === index ? "open" : "closed")}"
|
|
136
|
+
class="${triggerClasses}"
|
|
137
|
+
@click="${() => (openIndex() === index ? close() : openIndex(index))}"
|
|
138
|
+
@pointerenter="${() => schedule(index, OPEN_DELAY_MS)}"
|
|
139
|
+
@pointerleave="${() => schedule(-1, CLOSE_DELAY_MS)}"
|
|
140
|
+
@keydown="${(event: KeyboardEvent) => {
|
|
141
|
+
if (
|
|
142
|
+
event.key === "ArrowDown" ||
|
|
143
|
+
event.key === "Enter" ||
|
|
144
|
+
event.key === " "
|
|
145
|
+
) {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
openIndex(index);
|
|
148
|
+
}
|
|
149
|
+
}}"
|
|
150
|
+
>
|
|
151
|
+
${item.label}
|
|
152
|
+
${ChevronDownIcon({
|
|
153
|
+
class:
|
|
154
|
+
"relative top-px size-3 transition-transform duration-200 group-data-[state=open]:rotate-180",
|
|
155
|
+
})}
|
|
156
|
+
</button>
|
|
157
|
+
</li>`;
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
function renderPanel(item: NavigationItem | undefined): Child {
|
|
162
|
+
if (item === undefined) return null;
|
|
163
|
+
if (item.content !== undefined) return slot(item.content)();
|
|
164
|
+
|
|
165
|
+
return html`<ul class="grid w-[400px] gap-1 md:w-[500px] md:grid-cols-2">
|
|
166
|
+
${(item.links ?? []).map(
|
|
167
|
+
(link) => html`<li>
|
|
168
|
+
<a
|
|
169
|
+
href="${link.href}"
|
|
170
|
+
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"
|
|
171
|
+
>
|
|
172
|
+
<div class="text-sm leading-none font-medium">${link.label}</div>
|
|
173
|
+
${
|
|
174
|
+
link.description === undefined
|
|
175
|
+
? null
|
|
176
|
+
: html`<p class="text-muted-foreground line-clamp-2 text-sm leading-snug">
|
|
177
|
+
${link.description}
|
|
178
|
+
</p>`
|
|
179
|
+
}
|
|
180
|
+
</a>
|
|
181
|
+
</li>`,
|
|
182
|
+
)}
|
|
183
|
+
</ul>`;
|
|
184
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
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
|
+
|
|
14
|
+
import { component, html } from "@c9up/aurora";
|
|
15
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
16
|
+
import { cn } from "../lib/cn.js";
|
|
17
|
+
import { uid } from "../lib/id.js";
|
|
18
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
19
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
20
|
+
import { controllable } from "../primitives/controllable.js";
|
|
21
|
+
import type { Placement } from "../primitives/floating.js";
|
|
22
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
23
|
+
|
|
24
|
+
export const popoverContentClasses =
|
|
25
|
+
"bg-popover text-popover-foreground z-50 w-72 rounded-md border p-4 shadow-md outline-none";
|
|
26
|
+
|
|
27
|
+
export interface PopoverProps {
|
|
28
|
+
/** Rendered inside the trigger button. */
|
|
29
|
+
trigger?: Slot;
|
|
30
|
+
/** The panel's contents. */
|
|
31
|
+
children?: Slot;
|
|
32
|
+
open?: Reactive<boolean>;
|
|
33
|
+
defaultOpen?: boolean;
|
|
34
|
+
onOpenChange?: (open: boolean) => void;
|
|
35
|
+
placement?: Placement;
|
|
36
|
+
offset?: number;
|
|
37
|
+
/** Trap focus inside. Use for panels holding form controls. */
|
|
38
|
+
modal?: boolean;
|
|
39
|
+
class?: Reactive<string>;
|
|
40
|
+
triggerClass?: Reactive<string>;
|
|
41
|
+
contentClass?: Reactive<string>;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Popover = component<PopoverProps>((props) => {
|
|
45
|
+
const triggerId = uid("popover-trigger");
|
|
46
|
+
const contentId = uid("popover-content");
|
|
47
|
+
|
|
48
|
+
const state = controllable<boolean>({
|
|
49
|
+
value: props.open,
|
|
50
|
+
initial: props.defaultOpen ?? false,
|
|
51
|
+
onChange: props.onOpenChange,
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
floatingSurface({
|
|
55
|
+
anchor: () => document.getElementById(triggerId),
|
|
56
|
+
open: () => state.current(),
|
|
57
|
+
onClose: () => state.set(false),
|
|
58
|
+
placement: props.placement ?? "bottom",
|
|
59
|
+
offset: props.offset ?? 4,
|
|
60
|
+
trapFocus: props.modal === true,
|
|
61
|
+
autoFocus: props.modal !== true,
|
|
62
|
+
content: () =>
|
|
63
|
+
html`<div
|
|
64
|
+
data-slot="popover-content"
|
|
65
|
+
id="${contentId}"
|
|
66
|
+
role="dialog"
|
|
67
|
+
aria-labelledby="${triggerId}"
|
|
68
|
+
tabindex="-1"
|
|
69
|
+
class="${cn(popoverContentClasses, zoomInOut, read(props.contentClass))}"
|
|
70
|
+
>${slot(props.children)}</div>`,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return html`<button
|
|
74
|
+
type="button"
|
|
75
|
+
data-slot="popover-trigger"
|
|
76
|
+
id="${triggerId}"
|
|
77
|
+
aria-haspopup="dialog"
|
|
78
|
+
aria-expanded="${() => (state.current() ? "true" : "false")}"
|
|
79
|
+
aria-controls="${() => (state.current() ? contentId : undefined)}"
|
|
80
|
+
data-state="${() => (state.current() ? "open" : "closed")}"
|
|
81
|
+
class="${() => cn(read(props.triggerClass))}"
|
|
82
|
+
@click="${() => state.set(!state.current())}"
|
|
83
|
+
>${slot(props.trigger)}</button>`;
|
|
84
|
+
});
|