@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,195 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Dismissable layers — Escape, click-outside and focus-outside, stacked.
|
|
4
|
+
*
|
|
5
|
+
* Every transient surface needs this: Popover, DropdownMenu, ContextMenu,
|
|
6
|
+
* Select, Combobox, Tooltip, Dialog, Sheet, Drawer. Wiring it per component
|
|
7
|
+
* is how you end up with a menu inside a dialog where Escape closes both, or
|
|
8
|
+
* a submenu whose parent collapses the moment you click into it.
|
|
9
|
+
*
|
|
10
|
+
* So layers live in one stack and the rules are applied once, globally:
|
|
11
|
+
*
|
|
12
|
+
* - **Escape** reaches only the topmost layer that accepts it. That is what
|
|
13
|
+
* makes Escape close a submenu first and its parent menu second.
|
|
14
|
+
* - **Pointer outside** walks the stack top-down and stops at the first layer
|
|
15
|
+
* the event landed inside. Layers above it close, layers below it stay. A
|
|
16
|
+
* click in a parent menu therefore closes the submenu and nothing else.
|
|
17
|
+
* - **Focus outside** follows the same walk, but only layers that opt in — a
|
|
18
|
+
* modal dialog holds focus by design and must not close because focus moved.
|
|
19
|
+
*
|
|
20
|
+
* Layers are portalled, so a nested surface is not a DOM descendant of its
|
|
21
|
+
* parent. Containment is answered per layer by its own element plus whatever
|
|
22
|
+
* it excludes (its trigger, almost always), never by tree position.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
export type DismissReason = "escape" | "outside-pointer" | "outside-focus";
|
|
26
|
+
|
|
27
|
+
export interface DismissableOptions {
|
|
28
|
+
/** The surface itself. Read lazily — it mounts after the layer registers. */
|
|
29
|
+
element: () => HTMLElement | null;
|
|
30
|
+
/** Called when this layer should close. */
|
|
31
|
+
onDismiss: (reason: DismissReason) => void;
|
|
32
|
+
/**
|
|
33
|
+
* Extra elements that do not count as "outside". The trigger belongs here:
|
|
34
|
+
* without it, pressing the trigger to close would dismiss on pointerdown and
|
|
35
|
+
* immediately reopen on click.
|
|
36
|
+
*/
|
|
37
|
+
exclude?: () => ReadonlyArray<HTMLElement | null | undefined>;
|
|
38
|
+
/** Escape dismisses this layer when it is topmost. Default `true`. */
|
|
39
|
+
escapeKey?: boolean;
|
|
40
|
+
/** A pointer press outside dismisses. Default `true`. */
|
|
41
|
+
outsidePointer?: boolean;
|
|
42
|
+
/** Focus moving outside dismisses. Default `false`. */
|
|
43
|
+
outsideFocus?: boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DismissableLayer {
|
|
47
|
+
/** Remove this layer from the stack. Safe to call twice. */
|
|
48
|
+
remove(): void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface Layer extends DismissableOptions {
|
|
52
|
+
readonly id: number;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const stack: Layer[] = [];
|
|
56
|
+
let nextLayerId = 0;
|
|
57
|
+
let listening = false;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Register a dismissable layer. The returned handle must be removed when the
|
|
61
|
+
* surface unmounts — a stale layer keeps answering Escape for a surface that
|
|
62
|
+
* is no longer on screen.
|
|
63
|
+
*/
|
|
64
|
+
export function dismissable(options: DismissableOptions): DismissableLayer {
|
|
65
|
+
nextLayerId += 1;
|
|
66
|
+
const layer: Layer = { ...options, id: nextLayerId };
|
|
67
|
+
stack.push(layer);
|
|
68
|
+
startListening();
|
|
69
|
+
|
|
70
|
+
let removed = false;
|
|
71
|
+
return {
|
|
72
|
+
remove(): void {
|
|
73
|
+
if (removed) return;
|
|
74
|
+
removed = true;
|
|
75
|
+
const index = stack.findIndex((entry) => entry.id === layer.id);
|
|
76
|
+
if (index !== -1) stack.splice(index, 1);
|
|
77
|
+
stopListeningIfIdle();
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** How many layers are currently open. Used by the Escape/scroll-lock tests. */
|
|
83
|
+
export function layerCount(): number {
|
|
84
|
+
return stack.length;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ─── global listeners ────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Listeners attach on the first layer and detach with the last, rather than
|
|
91
|
+
* living for the lifetime of the page. An app that never opens an overlay
|
|
92
|
+
* pays nothing, and a test that opens and closes one leaves no residue.
|
|
93
|
+
*/
|
|
94
|
+
function startListening(): void {
|
|
95
|
+
if (listening || typeof document === "undefined") return;
|
|
96
|
+
listening = true;
|
|
97
|
+
document.addEventListener("keydown", onKeyDown, true);
|
|
98
|
+
document.addEventListener("pointerdown", onPointerDown, true);
|
|
99
|
+
document.addEventListener("focusin", onFocusIn, true);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function stopListeningIfIdle(): void {
|
|
103
|
+
if (!listening || stack.length > 0) return;
|
|
104
|
+
listening = false;
|
|
105
|
+
document.removeEventListener("keydown", onKeyDown, true);
|
|
106
|
+
document.removeEventListener("pointerdown", onPointerDown, true);
|
|
107
|
+
document.removeEventListener("focusin", onFocusIn, true);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
111
|
+
if (event.key !== "Escape") return;
|
|
112
|
+
|
|
113
|
+
// Topmost layer that accepts Escape — not simply the topmost layer. A
|
|
114
|
+
// Tooltip stacked over a Popover opts out, and Escape must then reach the
|
|
115
|
+
// Popover rather than being swallowed.
|
|
116
|
+
for (let i = stack.length - 1; i >= 0; i -= 1) {
|
|
117
|
+
const layer = stack[i];
|
|
118
|
+
if (layer === undefined) continue;
|
|
119
|
+
if (layer.escapeKey === false) continue;
|
|
120
|
+
event.stopPropagation();
|
|
121
|
+
layer.onDismiss("escape");
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function onPointerDown(event: Event): void {
|
|
127
|
+
dismissOutside(event, "outside-pointer");
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onFocusIn(event: Event): void {
|
|
131
|
+
dismissOutside(event, "outside-focus");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Close every layer the event landed outside of, stopping at the first one it
|
|
136
|
+
* landed inside.
|
|
137
|
+
*
|
|
138
|
+
* The snapshot is taken before dismissing: `onDismiss` mutates the stack, and
|
|
139
|
+
* iterating the live array while it shrinks skips layers.
|
|
140
|
+
*/
|
|
141
|
+
function dismissOutside(event: Event, reason: DismissReason): void {
|
|
142
|
+
const path = eventPath(event);
|
|
143
|
+
const snapshot = stack.slice();
|
|
144
|
+
|
|
145
|
+
for (let i = snapshot.length - 1; i >= 0; i -= 1) {
|
|
146
|
+
const layer = snapshot[i];
|
|
147
|
+
if (layer === undefined) continue;
|
|
148
|
+
if (containsEvent(layer, path)) return;
|
|
149
|
+
|
|
150
|
+
const optedIn =
|
|
151
|
+
reason === "outside-pointer"
|
|
152
|
+
? layer.outsidePointer !== false
|
|
153
|
+
: layer.outsideFocus === true;
|
|
154
|
+
|
|
155
|
+
// A layer that opted out still blocks: closing the one beneath it while
|
|
156
|
+
// it stays open would leave the stack inverted on screen.
|
|
157
|
+
if (!optedIn) return;
|
|
158
|
+
layer.onDismiss(reason);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Did the event originate inside this layer, or inside anything it excludes? */
|
|
163
|
+
function containsEvent(layer: Layer, path: readonly EventTarget[]): boolean {
|
|
164
|
+
const element = layer.element();
|
|
165
|
+
if (element !== null && path.includes(element)) return true;
|
|
166
|
+
|
|
167
|
+
const excluded = layer.exclude?.() ?? [];
|
|
168
|
+
for (const candidate of excluded) {
|
|
169
|
+
if (candidate && path.includes(candidate)) return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The nodes an event travelled through.
|
|
176
|
+
*
|
|
177
|
+
* `composedPath()` is what makes containment correct across a shadow root,
|
|
178
|
+
* where `event.target` is retargeted to the host and a naive `contains()`
|
|
179
|
+
* check reports the wrong answer. Not every DOM implementation nebula runs
|
|
180
|
+
* against provides it, so the ancestor walk stands in when it is missing.
|
|
181
|
+
*/
|
|
182
|
+
function eventPath(event: Event): readonly EventTarget[] {
|
|
183
|
+
if (typeof event.composedPath === "function") {
|
|
184
|
+
const path = event.composedPath();
|
|
185
|
+
if (path.length > 0) return path;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const walked: EventTarget[] = [];
|
|
189
|
+
let node: Node | null = event.target instanceof Node ? event.target : null;
|
|
190
|
+
while (node !== null) {
|
|
191
|
+
walked.push(node);
|
|
192
|
+
node = node.parentNode;
|
|
193
|
+
}
|
|
194
|
+
return walked;
|
|
195
|
+
}
|
|
@@ -0,0 +1,453 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Floating placement — where a popover, menu, select or tooltip goes.
|
|
4
|
+
*
|
|
5
|
+
* This is the piece Radix delegates to `@floating-ui/dom`. nebula computes it
|
|
6
|
+
* instead, because the alternative is a runtime dependency in a workspace
|
|
7
|
+
* whose stated position is that `cn` was worth reimplementing rather than
|
|
8
|
+
* pulling in clsx and tailwind-merge.
|
|
9
|
+
*
|
|
10
|
+
* Scope is chosen to make that defensible. Floating UI is a general middleware
|
|
11
|
+
* pipeline; this is the four behaviours the shadcn component set actually
|
|
12
|
+
* uses — `offset`, `flip`, `shift`, and arrow centring — plus the available
|
|
13
|
+
* height a scrollable menu needs for its `max-height`. No middleware
|
|
14
|
+
* abstraction, no virtual elements, no `autoPlacement`.
|
|
15
|
+
*
|
|
16
|
+
* The geometry lives in `resolvePosition`, a pure function over rectangles.
|
|
17
|
+
* Everything that touches the DOM — measuring, writing styles, watching for
|
|
18
|
+
* scroll — is in `autoPosition` around it. That split is what lets the flip
|
|
19
|
+
* and shift rules be tested exhaustively without a browser, which matters
|
|
20
|
+
* because they are where placement bugs actually live.
|
|
21
|
+
*
|
|
22
|
+
* Coordinates are viewport-relative and meant for `position: fixed`. Fixed
|
|
23
|
+
* positioning is what lets a portalled surface ignore every transformed or
|
|
24
|
+
* clipping ancestor between it and the root; an absolutely positioned popover
|
|
25
|
+
* has to find an offset parent, and gets it wrong the moment one appears.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
export type Side = "top" | "right" | "bottom" | "left";
|
|
29
|
+
export type Align = "start" | "center" | "end";
|
|
30
|
+
export type Placement = Side | `${Side}-${Align}`;
|
|
31
|
+
|
|
32
|
+
/** Just the fields of a DOMRect the maths needs. */
|
|
33
|
+
export interface Rect {
|
|
34
|
+
readonly x: number;
|
|
35
|
+
readonly y: number;
|
|
36
|
+
readonly width: number;
|
|
37
|
+
readonly height: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface Viewport {
|
|
41
|
+
readonly width: number;
|
|
42
|
+
readonly height: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface PositionOptions {
|
|
46
|
+
/** Preferred placement. Default `"bottom"`. */
|
|
47
|
+
placement?: Placement;
|
|
48
|
+
/** Gap between anchor and surface, in px. Default `4`. */
|
|
49
|
+
offset?: number;
|
|
50
|
+
/** Keep this much clearance from the viewport edge. Default `8`. */
|
|
51
|
+
padding?: number;
|
|
52
|
+
/** Flip to the opposite side when the preferred one does not fit. Default `true`. */
|
|
53
|
+
flip?: boolean;
|
|
54
|
+
/** Slide along the cross axis to stay on screen. Default `true`. */
|
|
55
|
+
shift?: boolean;
|
|
56
|
+
/** Arrow size in px; centres the arrow and keeps it off the corners. */
|
|
57
|
+
arrowSize?: number;
|
|
58
|
+
/**
|
|
59
|
+
* Mirror the placement for a right-to-left writing direction.
|
|
60
|
+
*
|
|
61
|
+
* Placements are written in physical terms — `"right-start"` for a submenu —
|
|
62
|
+
* because that is what reads clearly at the call site and what CSS ends up
|
|
63
|
+
* applying. In Arabic or Hebrew those names mean the wrong edge: a submenu
|
|
64
|
+
* belongs on the *inline end*, which is the left. Rather than making every
|
|
65
|
+
* component reason in logical directions, the mirror happens once, here.
|
|
66
|
+
*
|
|
67
|
+
* `autoPosition` reads it off the anchor's computed direction, so a component
|
|
68
|
+
* never has to pass it.
|
|
69
|
+
*/
|
|
70
|
+
rtl?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface Position {
|
|
74
|
+
readonly x: number;
|
|
75
|
+
readonly y: number;
|
|
76
|
+
/** Placement actually used — may differ from the request after a flip. */
|
|
77
|
+
readonly placement: Placement;
|
|
78
|
+
readonly side: Side;
|
|
79
|
+
readonly align: Align;
|
|
80
|
+
/**
|
|
81
|
+
* Space between the surface's edge and the viewport on the resolved side.
|
|
82
|
+
* Menus bind it to `max-height` so a long list scrolls instead of
|
|
83
|
+
* overflowing off screen.
|
|
84
|
+
*/
|
|
85
|
+
readonly availableHeight: number;
|
|
86
|
+
/** Arrow offset along the surface's cross axis, when `arrowSize` was given. */
|
|
87
|
+
readonly arrow?: { readonly x?: number; readonly y?: number };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const DEFAULT_OFFSET = 4;
|
|
91
|
+
const DEFAULT_PADDING = 8;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Compute where the surface goes. Pure — no DOM, no side effects.
|
|
95
|
+
*
|
|
96
|
+
* Order matters and matches Floating UI's: offset, then flip, then shift.
|
|
97
|
+
* Shifting before flipping would slide a surface halfway off the anchor to
|
|
98
|
+
* make room on a side that should have been abandoned entirely.
|
|
99
|
+
*/
|
|
100
|
+
export function resolvePosition(
|
|
101
|
+
anchor: Rect,
|
|
102
|
+
floating: Rect,
|
|
103
|
+
viewport: Viewport,
|
|
104
|
+
options: PositionOptions = {},
|
|
105
|
+
): Position {
|
|
106
|
+
const offset = options.offset ?? DEFAULT_OFFSET;
|
|
107
|
+
const padding = options.padding ?? DEFAULT_PADDING;
|
|
108
|
+
const requested = options.placement ?? "bottom";
|
|
109
|
+
const [physicalSide, physicalAlign] = splitPlacement(requested);
|
|
110
|
+
const [requestedSide, align] =
|
|
111
|
+
options.rtl === true
|
|
112
|
+
? mirrorForRtl(physicalSide, physicalAlign)
|
|
113
|
+
: [physicalSide, physicalAlign];
|
|
114
|
+
|
|
115
|
+
const side =
|
|
116
|
+
options.flip === false
|
|
117
|
+
? requestedSide
|
|
118
|
+
: bestSide(requestedSide, anchor, floating, viewport, offset, padding);
|
|
119
|
+
|
|
120
|
+
let { x, y } = coordsFor(side, align, anchor, floating, offset);
|
|
121
|
+
|
|
122
|
+
if (options.shift !== false) {
|
|
123
|
+
if (isVertical(side)) {
|
|
124
|
+
x = clamp(x, padding, viewport.width - floating.width - padding);
|
|
125
|
+
} else {
|
|
126
|
+
y = clamp(y, padding, viewport.height - floating.height - padding);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const position: Position = {
|
|
131
|
+
x,
|
|
132
|
+
y,
|
|
133
|
+
placement: align === "center" ? side : `${side}-${align}`,
|
|
134
|
+
side,
|
|
135
|
+
align,
|
|
136
|
+
availableHeight: spaceOn(side, anchor, viewport, offset, padding),
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
if (options.arrowSize === undefined) return position;
|
|
140
|
+
return {
|
|
141
|
+
...position,
|
|
142
|
+
arrow: arrowOffset(
|
|
143
|
+
side,
|
|
144
|
+
anchor,
|
|
145
|
+
floating,
|
|
146
|
+
{ x, y },
|
|
147
|
+
options.arrowSize,
|
|
148
|
+
padding,
|
|
149
|
+
),
|
|
150
|
+
};
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ─── geometry ────────────────────────────────────────────────────────
|
|
154
|
+
|
|
155
|
+
function splitPlacement(placement: Placement): [Side, Align] {
|
|
156
|
+
const dash = placement.indexOf("-");
|
|
157
|
+
if (dash === -1) return [toSide(placement), "center"];
|
|
158
|
+
return [toSide(placement.slice(0, dash)), toAlign(placement.slice(dash + 1))];
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Narrow a substring back to `Side` / `Align`.
|
|
163
|
+
*
|
|
164
|
+
* `splitPlacement` slices a string the type system already proved is a valid
|
|
165
|
+
* `Placement`, but slicing erases that. A guard restores it without a cast,
|
|
166
|
+
* and the fallback keeps a hand-built placement string from crashing the
|
|
167
|
+
* layout — it lands on the default instead.
|
|
168
|
+
*/
|
|
169
|
+
function toSide(value: string): Side {
|
|
170
|
+
return value === "top" || value === "right" || value === "left"
|
|
171
|
+
? value
|
|
172
|
+
: "bottom";
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function toAlign(value: string): Align {
|
|
176
|
+
return value === "start" || value === "end" ? value : "center";
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function isVertical(side: Side): boolean {
|
|
180
|
+
return side === "top" || side === "bottom";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Flip a placement across the vertical axis.
|
|
185
|
+
*
|
|
186
|
+
* Which half moves depends on the side. For `left` / `right` the *side* is the
|
|
187
|
+
* horizontal thing, so it swaps and the alignment (top/bottom) is untouched.
|
|
188
|
+
* For `top` / `bottom` the side is unaffected and it is the *alignment* that
|
|
189
|
+
* refers to a horizontal edge, so that swaps instead. Mirroring both would put
|
|
190
|
+
* a `bottom-start` menu back where it started.
|
|
191
|
+
*/
|
|
192
|
+
function mirrorForRtl(side: Side, align: Align): [Side, Align] {
|
|
193
|
+
if (!isVertical(side)) return [opposite(side), align];
|
|
194
|
+
if (align === "center") return [side, align];
|
|
195
|
+
return [side, align === "start" ? "end" : "start"];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function opposite(side: Side): Side {
|
|
199
|
+
if (side === "top") return "bottom";
|
|
200
|
+
if (side === "bottom") return "top";
|
|
201
|
+
if (side === "left") return "right";
|
|
202
|
+
return "left";
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Top-left corner of the surface for a given side and alignment. */
|
|
206
|
+
function coordsFor(
|
|
207
|
+
side: Side,
|
|
208
|
+
align: Align,
|
|
209
|
+
anchor: Rect,
|
|
210
|
+
floating: Rect,
|
|
211
|
+
offset: number,
|
|
212
|
+
): { x: number; y: number } {
|
|
213
|
+
if (isVertical(side)) {
|
|
214
|
+
const y =
|
|
215
|
+
side === "bottom"
|
|
216
|
+
? anchor.y + anchor.height + offset
|
|
217
|
+
: anchor.y - floating.height - offset;
|
|
218
|
+
return {
|
|
219
|
+
x: alignedStart(anchor.x, anchor.width, floating.width, align),
|
|
220
|
+
y,
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const x =
|
|
225
|
+
side === "right"
|
|
226
|
+
? anchor.x + anchor.width + offset
|
|
227
|
+
: anchor.x - floating.width - offset;
|
|
228
|
+
return {
|
|
229
|
+
x,
|
|
230
|
+
y: alignedStart(anchor.y, anchor.height, floating.height, align),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Where the surface starts along the cross axis, for one alignment. */
|
|
235
|
+
function alignedStart(
|
|
236
|
+
anchorStart: number,
|
|
237
|
+
anchorSize: number,
|
|
238
|
+
floatingSize: number,
|
|
239
|
+
align: Align,
|
|
240
|
+
): number {
|
|
241
|
+
if (align === "start") return anchorStart;
|
|
242
|
+
if (align === "end") return anchorStart + anchorSize - floatingSize;
|
|
243
|
+
return anchorStart + (anchorSize - floatingSize) / 2;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** Room between the anchor and the viewport edge on one side. */
|
|
247
|
+
function spaceOn(
|
|
248
|
+
side: Side,
|
|
249
|
+
anchor: Rect,
|
|
250
|
+
viewport: Viewport,
|
|
251
|
+
offset: number,
|
|
252
|
+
padding: number,
|
|
253
|
+
): number {
|
|
254
|
+
const raw =
|
|
255
|
+
side === "top"
|
|
256
|
+
? anchor.y
|
|
257
|
+
: side === "bottom"
|
|
258
|
+
? viewport.height - (anchor.y + anchor.height)
|
|
259
|
+
: side === "left"
|
|
260
|
+
? anchor.x
|
|
261
|
+
: viewport.width - (anchor.x + anchor.width);
|
|
262
|
+
return Math.max(0, raw - offset - padding);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Keep the requested side unless the opposite one is genuinely better.
|
|
267
|
+
*
|
|
268
|
+
* "Better" is not "has more room": flipping a menu that overflows by two
|
|
269
|
+
* pixels is more disruptive than letting it shift. So the opposite side has to
|
|
270
|
+
* both fit and beat the requested one. When neither fits, the roomier side
|
|
271
|
+
* wins and `availableHeight` lets the surface scroll inside it.
|
|
272
|
+
*/
|
|
273
|
+
function bestSide(
|
|
274
|
+
requested: Side,
|
|
275
|
+
anchor: Rect,
|
|
276
|
+
floating: Rect,
|
|
277
|
+
viewport: Viewport,
|
|
278
|
+
offset: number,
|
|
279
|
+
padding: number,
|
|
280
|
+
): Side {
|
|
281
|
+
const needed = isVertical(requested) ? floating.height : floating.width;
|
|
282
|
+
const here = spaceOn(requested, anchor, viewport, offset, padding);
|
|
283
|
+
if (here >= needed) return requested;
|
|
284
|
+
|
|
285
|
+
const other = opposite(requested);
|
|
286
|
+
const there = spaceOn(other, anchor, viewport, offset, padding);
|
|
287
|
+
if (there >= needed) return other;
|
|
288
|
+
|
|
289
|
+
return there > here ? other : requested;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Centre the arrow on the anchor, then pull it back off the corners.
|
|
294
|
+
*
|
|
295
|
+
* The arrow tracks the *anchor*, not the surface: once shift has slid the
|
|
296
|
+
* surface sideways, an arrow centred on the surface points at empty space.
|
|
297
|
+
* The clamp keeps it clear of the rounded corners, where it would otherwise
|
|
298
|
+
* poke out of the border radius.
|
|
299
|
+
*/
|
|
300
|
+
function arrowOffset(
|
|
301
|
+
side: Side,
|
|
302
|
+
anchor: Rect,
|
|
303
|
+
floating: Rect,
|
|
304
|
+
coords: { x: number; y: number },
|
|
305
|
+
arrowSize: number,
|
|
306
|
+
padding: number,
|
|
307
|
+
): { x?: number; y?: number } {
|
|
308
|
+
if (isVertical(side)) {
|
|
309
|
+
const centre = anchor.x + anchor.width / 2 - coords.x - arrowSize / 2;
|
|
310
|
+
const limit = floating.width - arrowSize - padding;
|
|
311
|
+
return { x: clamp(centre, padding, Math.max(padding, limit)) };
|
|
312
|
+
}
|
|
313
|
+
const centre = anchor.y + anchor.height / 2 - coords.y - arrowSize / 2;
|
|
314
|
+
const limit = floating.height - arrowSize - padding;
|
|
315
|
+
return { y: clamp(centre, padding, Math.max(padding, limit)) };
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function clamp(value: number, min: number, max: number): number {
|
|
319
|
+
// `max` can fall below `min` when the surface is wider than the viewport.
|
|
320
|
+
// Pinning to `min` keeps the leading edge visible, which is the readable
|
|
321
|
+
// half; honouring `max` instead would push the start off screen.
|
|
322
|
+
if (max < min) return min;
|
|
323
|
+
return Math.min(Math.max(value, min), max);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ─── DOM binding ─────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
export interface AutoPositionOptions extends PositionOptions {
|
|
329
|
+
/** The arrow element to position, if the surface has one. */
|
|
330
|
+
arrow?: () => HTMLElement | null;
|
|
331
|
+
/** Force the surface to the anchor's width. Select and Combobox use it. */
|
|
332
|
+
matchWidth?: boolean;
|
|
333
|
+
/** Called after each reposition — for `data-side` attributes and the like. */
|
|
334
|
+
onPositioned?: (position: Position) => void;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export interface AutoPosition {
|
|
338
|
+
/** Recompute and rewrite the styles now. */
|
|
339
|
+
update(): void;
|
|
340
|
+
/** Stop watching. Styles already written are left in place. */
|
|
341
|
+
stop(): void;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Position `floating` against `anchor` and keep it there.
|
|
346
|
+
*
|
|
347
|
+
* Updates run on scroll (captured, so ancestor scroll containers are covered
|
|
348
|
+
* without walking the tree to find them) and on resize, plus whenever either
|
|
349
|
+
* element changes size where `ResizeObserver` exists. A menu whose content
|
|
350
|
+
* loads in and grows would otherwise stay positioned for its old height.
|
|
351
|
+
*/
|
|
352
|
+
export function autoPosition(
|
|
353
|
+
anchor: HTMLElement,
|
|
354
|
+
floating: HTMLElement,
|
|
355
|
+
options: AutoPositionOptions = {},
|
|
356
|
+
): AutoPosition {
|
|
357
|
+
function update(): void {
|
|
358
|
+
const anchorRect = anchor.getBoundingClientRect();
|
|
359
|
+
// Read per update, not once: a `dir` attribute can be toggled at runtime
|
|
360
|
+
// by a language switcher, and a surface open across that switch has to
|
|
361
|
+
// move with it.
|
|
362
|
+
const rtl = options.rtl ?? isRightToLeft(anchor);
|
|
363
|
+
if (options.matchWidth === true) {
|
|
364
|
+
floating.style.width = `${anchorRect.width}px`;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
const floatingRect = floating.getBoundingClientRect();
|
|
368
|
+
const position = resolvePosition(
|
|
369
|
+
anchorRect,
|
|
370
|
+
floatingRect,
|
|
371
|
+
{ width: window.innerWidth, height: window.innerHeight },
|
|
372
|
+
{ ...options, rtl },
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
floating.style.position = "fixed";
|
|
376
|
+
floating.style.left = `${Math.round(position.x)}px`;
|
|
377
|
+
floating.style.top = `${Math.round(position.y)}px`;
|
|
378
|
+
|
|
379
|
+
// Exposed as custom properties so the *stylesheet* decides what to do with
|
|
380
|
+
// them. shadcn's menus read the available height through
|
|
381
|
+
// `max-h-(--nebula-available-height)`, which keeps the scroll decision in
|
|
382
|
+
// CSS rather than hardcoding a pixel max-height from script.
|
|
383
|
+
floating.style.setProperty(
|
|
384
|
+
"--nebula-available-height",
|
|
385
|
+
`${Math.round(position.availableHeight)}px`,
|
|
386
|
+
);
|
|
387
|
+
floating.style.setProperty(
|
|
388
|
+
"--nebula-anchor-width",
|
|
389
|
+
`${Math.round(anchorRect.width)}px`,
|
|
390
|
+
);
|
|
391
|
+
floating.setAttribute("data-side", position.side);
|
|
392
|
+
floating.setAttribute("data-align", position.align);
|
|
393
|
+
|
|
394
|
+
const arrowEl = options.arrow?.();
|
|
395
|
+
if (arrowEl && position.arrow) {
|
|
396
|
+
const { x, y } = position.arrow;
|
|
397
|
+
arrowEl.style.position = "absolute";
|
|
398
|
+
arrowEl.style.left = x === undefined ? "" : `${Math.round(x)}px`;
|
|
399
|
+
arrowEl.style.top = y === undefined ? "" : `${Math.round(y)}px`;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
options.onPositioned?.(position);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
update();
|
|
406
|
+
|
|
407
|
+
const onScrollOrResize = (): void => update();
|
|
408
|
+
window.addEventListener("scroll", onScrollOrResize, true);
|
|
409
|
+
window.addEventListener("resize", onScrollOrResize);
|
|
410
|
+
|
|
411
|
+
const observer = observeSizes([anchor, floating], update);
|
|
412
|
+
|
|
413
|
+
let stopped = false;
|
|
414
|
+
return {
|
|
415
|
+
update,
|
|
416
|
+
stop(): void {
|
|
417
|
+
if (stopped) return;
|
|
418
|
+
stopped = true;
|
|
419
|
+
window.removeEventListener("scroll", onScrollOrResize, true);
|
|
420
|
+
window.removeEventListener("resize", onScrollOrResize);
|
|
421
|
+
observer?.disconnect();
|
|
422
|
+
},
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Is this element laid out right to left?
|
|
428
|
+
*
|
|
429
|
+
* The computed style rather than the `dir` attribute, so an element inheriting
|
|
430
|
+
* the direction from `<html dir="rtl">` is answered correctly without every
|
|
431
|
+
* anchor having to carry the attribute itself.
|
|
432
|
+
*/
|
|
433
|
+
function isRightToLeft(element: HTMLElement): boolean {
|
|
434
|
+
if (typeof getComputedStyle !== "function") return false;
|
|
435
|
+
return getComputedStyle(element).direction === "rtl";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/**
|
|
439
|
+
* Watch elements for size changes, where the platform supports it.
|
|
440
|
+
*
|
|
441
|
+
* happy-dom (nebula's unit-test environment) has no `ResizeObserver`, and the
|
|
442
|
+
* scroll and resize listeners already cover the common cases, so its absence
|
|
443
|
+
* degrades rather than throws.
|
|
444
|
+
*/
|
|
445
|
+
function observeSizes(
|
|
446
|
+
elements: readonly HTMLElement[],
|
|
447
|
+
onResize: () => void,
|
|
448
|
+
): ResizeObserver | null {
|
|
449
|
+
if (typeof ResizeObserver !== "function") return null;
|
|
450
|
+
const observer = new ResizeObserver(onResize);
|
|
451
|
+
for (const element of elements) observer.observe(element);
|
|
452
|
+
return observer;
|
|
453
|
+
}
|