@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,491 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* The menu panel, shared by every menu-shaped surface.
|
|
4
|
+
*
|
|
5
|
+
* DropdownMenu, ContextMenu and Menubar differ only in what opens them — a
|
|
6
|
+
* button, a right-click, a bar of buttons. The panel itself, its entries, its
|
|
7
|
+
* keyboard model and its submenus are identical, and live here.
|
|
8
|
+
*
|
|
9
|
+
* **Entries are data.** A menu is a list, and describing it as one is what
|
|
10
|
+
* lets the panel enforce things no individual item can see: which radio in a
|
|
11
|
+
* group is selected, where the separators fall, and — the one that matters
|
|
12
|
+
* most — that activating any item closes the whole stack, submenus included.
|
|
13
|
+
*
|
|
14
|
+
* **Submenus are imperative.** `floatingSurface` registers mount and unmount
|
|
15
|
+
* hooks, so it can only be called from a component setup. A submenu opens from
|
|
16
|
+
* inside portalled content, long after setup has run, so it wires `portal`,
|
|
17
|
+
* `autoPosition` and `dismissable` directly. The stack of open submenus is
|
|
18
|
+
* held here and unwound together.
|
|
19
|
+
*
|
|
20
|
+
* The keyboard model is the WAI-ARIA menu pattern: arrows move, Home and End
|
|
21
|
+
* jump, typing seeks, ArrowRight opens a submenu, ArrowLeft closes it, Escape
|
|
22
|
+
* closes one level, and activating an item closes everything.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { html, type TemplateResult } from "@c9up/aurora";
|
|
26
|
+
import type { Child } from "../lib/children.js";
|
|
27
|
+
import { cn } from "../lib/cn.js";
|
|
28
|
+
import { CheckIcon, ChevronRightIcon, DotIcon } from "../lib/icons.js";
|
|
29
|
+
import { uid } from "../lib/id.js";
|
|
30
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
31
|
+
import { dismissable } from "./dismissable.js";
|
|
32
|
+
import { autoPosition } from "./floating.js";
|
|
33
|
+
import { focusSilently } from "./focusable.js";
|
|
34
|
+
import { portal } from "./portal.js";
|
|
35
|
+
import { rovingFocus } from "./rovingFocus.js";
|
|
36
|
+
import { typeahead } from "./typeahead.js";
|
|
37
|
+
|
|
38
|
+
// ─── entries ─────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export interface MenuAction {
|
|
41
|
+
type?: "item";
|
|
42
|
+
label: Child;
|
|
43
|
+
icon?: Child;
|
|
44
|
+
/** Rendered right-aligned. Display only — bind the real shortcut yourself. */
|
|
45
|
+
shortcut?: string;
|
|
46
|
+
disabled?: boolean;
|
|
47
|
+
/** Styles the entry as a destructive action. */
|
|
48
|
+
destructive?: boolean;
|
|
49
|
+
onSelect?: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MenuCheckbox {
|
|
53
|
+
type: "checkbox";
|
|
54
|
+
label: Child;
|
|
55
|
+
checked: boolean;
|
|
56
|
+
disabled?: boolean;
|
|
57
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface MenuRadioGroup {
|
|
61
|
+
type: "radio-group";
|
|
62
|
+
value: string;
|
|
63
|
+
options: ReadonlyArray<{ value: string; label: Child; disabled?: boolean }>;
|
|
64
|
+
onValueChange?: (value: string) => void;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface MenuLabel {
|
|
68
|
+
type: "label";
|
|
69
|
+
label: Child;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface MenuSeparator {
|
|
73
|
+
type: "separator";
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface MenuSubmenu {
|
|
77
|
+
type: "submenu";
|
|
78
|
+
label: Child;
|
|
79
|
+
icon?: Child;
|
|
80
|
+
disabled?: boolean;
|
|
81
|
+
entries: readonly MenuEntry[];
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export type MenuEntry =
|
|
85
|
+
| MenuAction
|
|
86
|
+
| MenuCheckbox
|
|
87
|
+
| MenuRadioGroup
|
|
88
|
+
| MenuLabel
|
|
89
|
+
| MenuSeparator
|
|
90
|
+
| MenuSubmenu;
|
|
91
|
+
|
|
92
|
+
// ─── classes ─────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export const menuPanelClasses =
|
|
95
|
+
"bg-popover text-popover-foreground z-50 min-w-[8rem] max-h-(--nebula-available-height) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md outline-none";
|
|
96
|
+
|
|
97
|
+
const itemClasses =
|
|
98
|
+
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4";
|
|
99
|
+
|
|
100
|
+
const destructiveClasses =
|
|
101
|
+
"text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive";
|
|
102
|
+
|
|
103
|
+
const indentedItemClasses = `${itemClasses} py-1.5 pr-2 pl-8`;
|
|
104
|
+
|
|
105
|
+
// ─── rendering ───────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
export interface MenuPanelOptions {
|
|
108
|
+
id: string;
|
|
109
|
+
entries: readonly MenuEntry[];
|
|
110
|
+
/** Close the whole menu stack — called after an item is activated. */
|
|
111
|
+
onCloseAll: () => void;
|
|
112
|
+
labelledBy?: string;
|
|
113
|
+
class?: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The panel's markup. Behaviour is attached separately by `wireMenu`. */
|
|
117
|
+
export function menuPanel(options: MenuPanelOptions): TemplateResult {
|
|
118
|
+
return html`<div
|
|
119
|
+
data-slot="menu-content"
|
|
120
|
+
id="${options.id}"
|
|
121
|
+
role="menu"
|
|
122
|
+
aria-labelledby="${options.labelledBy}"
|
|
123
|
+
aria-orientation="vertical"
|
|
124
|
+
tabindex="-1"
|
|
125
|
+
class="${cn(menuPanelClasses, zoomInOut, options.class)}"
|
|
126
|
+
>${options.entries.map((entry) => renderEntry(entry, options.onCloseAll))}</div>`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function renderEntry(entry: MenuEntry, closeAll: () => void): Child {
|
|
130
|
+
if (isSeparator(entry)) {
|
|
131
|
+
return html`<div role="separator" class="bg-border -mx-1 my-1 h-px"></div>`;
|
|
132
|
+
}
|
|
133
|
+
if (isLabel(entry)) {
|
|
134
|
+
return html`<div
|
|
135
|
+
role="presentation"
|
|
136
|
+
class="px-2 py-1.5 text-sm font-medium text-muted-foreground"
|
|
137
|
+
>${entry.label}</div>`;
|
|
138
|
+
}
|
|
139
|
+
if (isCheckbox(entry)) return renderCheckbox(entry, closeAll);
|
|
140
|
+
if (isRadioGroup(entry)) return renderRadioGroup(entry, closeAll);
|
|
141
|
+
if (isSubmenu(entry)) return renderSubmenuTrigger(entry);
|
|
142
|
+
return renderAction(entry, closeAll);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function renderAction(entry: MenuAction, closeAll: () => void): Child {
|
|
146
|
+
return html`<div
|
|
147
|
+
role="menuitem"
|
|
148
|
+
data-nebula-item
|
|
149
|
+
data-disabled="${entry.disabled === true ? "" : undefined}"
|
|
150
|
+
aria-disabled="${entry.disabled === true ? "true" : undefined}"
|
|
151
|
+
tabindex="-1"
|
|
152
|
+
class="${cn(itemClasses, entry.destructive === true ? destructiveClasses : "")}"
|
|
153
|
+
@click="${() => activate(entry.disabled, entry.onSelect, closeAll)}"
|
|
154
|
+
>
|
|
155
|
+
${entry.icon}
|
|
156
|
+
<span class="flex-1 truncate">${entry.label}</span>
|
|
157
|
+
${
|
|
158
|
+
entry.shortcut === undefined
|
|
159
|
+
? null
|
|
160
|
+
: html`<span class="text-muted-foreground ml-auto text-xs tracking-widest"
|
|
161
|
+
>${entry.shortcut}</span
|
|
162
|
+
>`
|
|
163
|
+
}
|
|
164
|
+
</div>`;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function renderCheckbox(entry: MenuCheckbox, closeAll: () => void): Child {
|
|
168
|
+
return html`<div
|
|
169
|
+
role="menuitemcheckbox"
|
|
170
|
+
data-nebula-item
|
|
171
|
+
aria-checked="${entry.checked ? "true" : "false"}"
|
|
172
|
+
data-disabled="${entry.disabled === true ? "" : undefined}"
|
|
173
|
+
tabindex="-1"
|
|
174
|
+
class="${indentedItemClasses}"
|
|
175
|
+
@click="${() =>
|
|
176
|
+
activate(
|
|
177
|
+
entry.disabled,
|
|
178
|
+
() => entry.onCheckedChange?.(!entry.checked),
|
|
179
|
+
closeAll,
|
|
180
|
+
)}"
|
|
181
|
+
>
|
|
182
|
+
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
183
|
+
${entry.checked ? CheckIcon({ class: "size-4" }) : null}
|
|
184
|
+
</span>
|
|
185
|
+
${entry.label}
|
|
186
|
+
</div>`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function renderRadioGroup(entry: MenuRadioGroup, closeAll: () => void): Child {
|
|
190
|
+
return html`<div role="group">
|
|
191
|
+
${entry.options.map(
|
|
192
|
+
(option) => html`<div
|
|
193
|
+
role="menuitemradio"
|
|
194
|
+
data-nebula-item
|
|
195
|
+
aria-checked="${option.value === entry.value ? "true" : "false"}"
|
|
196
|
+
data-disabled="${option.disabled === true ? "" : undefined}"
|
|
197
|
+
tabindex="-1"
|
|
198
|
+
class="${indentedItemClasses}"
|
|
199
|
+
@click="${() =>
|
|
200
|
+
activate(
|
|
201
|
+
option.disabled,
|
|
202
|
+
() => entry.onValueChange?.(option.value),
|
|
203
|
+
closeAll,
|
|
204
|
+
)}"
|
|
205
|
+
>
|
|
206
|
+
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
207
|
+
${option.value === entry.value ? DotIcon({ class: "size-2" }) : null}
|
|
208
|
+
</span>
|
|
209
|
+
${option.label}
|
|
210
|
+
</div>`,
|
|
211
|
+
)}
|
|
212
|
+
</div>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* A submenu's trigger row.
|
|
217
|
+
*
|
|
218
|
+
* The entries are stashed on the element through a WeakMap rather than an
|
|
219
|
+
* attribute — they contain callbacks and nested structures, and serialising
|
|
220
|
+
* those into the DOM is not possible. `wireMenu` reads them back when the row
|
|
221
|
+
* is opened.
|
|
222
|
+
*/
|
|
223
|
+
const submenuEntries = new WeakMap<Element, readonly MenuEntry[]>();
|
|
224
|
+
|
|
225
|
+
function renderSubmenuTrigger(entry: MenuSubmenu): Child {
|
|
226
|
+
const id = uid("menu-sub-trigger");
|
|
227
|
+
const row = html`<div
|
|
228
|
+
role="menuitem"
|
|
229
|
+
data-nebula-item
|
|
230
|
+
data-submenu="${id}"
|
|
231
|
+
data-disabled="${entry.disabled === true ? "" : undefined}"
|
|
232
|
+
aria-haspopup="menu"
|
|
233
|
+
aria-expanded="false"
|
|
234
|
+
tabindex="-1"
|
|
235
|
+
class="${itemClasses}"
|
|
236
|
+
>
|
|
237
|
+
${entry.icon}
|
|
238
|
+
<span class="flex-1 truncate">${entry.label}</span>
|
|
239
|
+
${ChevronRightIcon({ class: "ml-auto size-4" })}
|
|
240
|
+
</div>`;
|
|
241
|
+
|
|
242
|
+
pendingSubmenus.set(id, entry.entries);
|
|
243
|
+
return row;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Entries waiting to be claimed by their element.
|
|
248
|
+
*
|
|
249
|
+
* The trigger's entries are known while the template is built, but the element
|
|
250
|
+
* does not exist until it is mounted. This bridges the two: `wireMenu` moves
|
|
251
|
+
* each set from here onto the real element and clears the id, so nothing is
|
|
252
|
+
* retained after the menu closes.
|
|
253
|
+
*/
|
|
254
|
+
const pendingSubmenus = new Map<string, readonly MenuEntry[]>();
|
|
255
|
+
|
|
256
|
+
function activate(
|
|
257
|
+
disabled: boolean | undefined,
|
|
258
|
+
onSelect: (() => void) | undefined,
|
|
259
|
+
closeAll: () => void,
|
|
260
|
+
): void {
|
|
261
|
+
if (disabled === true) return;
|
|
262
|
+
onSelect?.();
|
|
263
|
+
closeAll();
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// ─── behaviour ───────────────────────────────────────────────────────
|
|
267
|
+
|
|
268
|
+
export interface WireMenuOptions {
|
|
269
|
+
/** Close the whole stack, back to the original trigger. */
|
|
270
|
+
onCloseAll: () => void;
|
|
271
|
+
/** Focus the first item as soon as the panel opens. */
|
|
272
|
+
autoFocusFirst?: boolean;
|
|
273
|
+
/**
|
|
274
|
+
* Close *this* panel and return focus to whatever opened it.
|
|
275
|
+
*
|
|
276
|
+
* Supplied only when wiring a submenu, and it is what makes ArrowLeft and
|
|
277
|
+
* Escape work inside one. A submenu is portalled, so its keydowns never
|
|
278
|
+
* bubble to the parent panel's listener — the parent hands down a way to be
|
|
279
|
+
* called instead. Without it the two keys fall through: ArrowLeft does
|
|
280
|
+
* nothing at all, and Escape reaches the document and closes the entire
|
|
281
|
+
* stack rather than one level.
|
|
282
|
+
*/
|
|
283
|
+
onCloseSelf?: () => void;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Attach the keyboard model and submenu handling to a live panel.
|
|
288
|
+
*
|
|
289
|
+
* Returns a teardown that also closes any submenus still open, which is what
|
|
290
|
+
* keeps a stack of three from leaving two behind when the root closes.
|
|
291
|
+
*/
|
|
292
|
+
export function wireMenu(
|
|
293
|
+
panel: HTMLElement,
|
|
294
|
+
options: WireMenuOptions,
|
|
295
|
+
): () => void {
|
|
296
|
+
claimSubmenuEntries(panel);
|
|
297
|
+
|
|
298
|
+
const open: Array<{ close(): void }> = [];
|
|
299
|
+
|
|
300
|
+
function closeSubmenusFrom(depth: number): void {
|
|
301
|
+
while (open.length > depth) open.pop()?.close();
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const group = rovingFocus({
|
|
305
|
+
container: () => panel,
|
|
306
|
+
itemSelector: "[data-nebula-item]",
|
|
307
|
+
orientation: "vertical",
|
|
308
|
+
// Moving off a submenu's trigger closes it, so the pointer travelling
|
|
309
|
+
// down a menu does not leave a trail of open panels behind it.
|
|
310
|
+
onFocusChange: (item) => {
|
|
311
|
+
if (item.getAttribute("data-submenu") === null) closeSubmenusFrom(0);
|
|
312
|
+
},
|
|
313
|
+
onSelect: (item) => {
|
|
314
|
+
if (item.getAttribute("data-submenu") !== null) openSubmenu(item);
|
|
315
|
+
else item.click();
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
group.sync();
|
|
319
|
+
|
|
320
|
+
const seek = typeahead({
|
|
321
|
+
items: () => group.items(),
|
|
322
|
+
onMatch: (item) => focusSilently(item),
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
function openSubmenu(trigger: HTMLElement): void {
|
|
326
|
+
const entries = submenuEntries.get(trigger);
|
|
327
|
+
if (entries === undefined || entries.length === 0) return;
|
|
328
|
+
closeSubmenusFrom(0);
|
|
329
|
+
|
|
330
|
+
const panelId = uid("menu-sub");
|
|
331
|
+
const mount = portal(
|
|
332
|
+
menuPanel({ id: panelId, entries, onCloseAll: options.onCloseAll }),
|
|
333
|
+
);
|
|
334
|
+
const element = mount.host.firstElementChild;
|
|
335
|
+
if (!(element instanceof HTMLElement)) {
|
|
336
|
+
mount.close();
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
element.setAttribute("data-state", "open");
|
|
341
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
342
|
+
trigger.setAttribute("aria-controls", panelId);
|
|
343
|
+
|
|
344
|
+
// Submenus sit beside their parent, overlapping it slightly, which is what
|
|
345
|
+
// lets the pointer travel diagonally into them without crossing a gap.
|
|
346
|
+
const position = autoPosition(trigger, element, {
|
|
347
|
+
placement: "right-start",
|
|
348
|
+
offset: -4,
|
|
349
|
+
});
|
|
350
|
+
|
|
351
|
+
const layer = dismissable({
|
|
352
|
+
element: () => element,
|
|
353
|
+
exclude: () => [trigger, panel],
|
|
354
|
+
onDismiss: () => closeSubmenusFrom(0),
|
|
355
|
+
escapeKey: false,
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const inner = wireMenu(element, {
|
|
359
|
+
...options,
|
|
360
|
+
onCloseSelf: () => {
|
|
361
|
+
closeSubmenusFrom(0);
|
|
362
|
+
focusSilently(trigger);
|
|
363
|
+
},
|
|
364
|
+
});
|
|
365
|
+
const firstItem = element.querySelector("[data-nebula-item]");
|
|
366
|
+
if (firstItem instanceof HTMLElement) focusSilently(firstItem);
|
|
367
|
+
|
|
368
|
+
open.push({
|
|
369
|
+
close(): void {
|
|
370
|
+
inner();
|
|
371
|
+
layer.remove();
|
|
372
|
+
position.stop();
|
|
373
|
+
mount.close();
|
|
374
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
375
|
+
trigger.removeAttribute("aria-controls");
|
|
376
|
+
},
|
|
377
|
+
});
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function onKeyDown(event: KeyboardEvent): void {
|
|
381
|
+
const active = document.activeElement;
|
|
382
|
+
const inside = active instanceof Node && panel.contains(active);
|
|
383
|
+
|
|
384
|
+
if (event.key === "ArrowRight" && inside) {
|
|
385
|
+
if (!(active instanceof HTMLElement)) return;
|
|
386
|
+
if (active.getAttribute("data-submenu") === null) return;
|
|
387
|
+
event.preventDefault();
|
|
388
|
+
event.stopPropagation();
|
|
389
|
+
openSubmenu(active);
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// One level per press for both keys: a submenu three deep takes three
|
|
394
|
+
// Escapes, not one. Closing a child we opened comes first; with none
|
|
395
|
+
// open, this panel is itself the child and asks its parent to close it.
|
|
396
|
+
// `dismissable` handles the last level, closing the root menu.
|
|
397
|
+
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
|
398
|
+
if (open.length > 0) {
|
|
399
|
+
event.preventDefault();
|
|
400
|
+
event.stopPropagation();
|
|
401
|
+
closeSubmenusFrom(open.length - 1);
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (options.onCloseSelf !== undefined) {
|
|
405
|
+
event.preventDefault();
|
|
406
|
+
event.stopPropagation();
|
|
407
|
+
options.onCloseSelf();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
// A root menu ignores ArrowLeft and lets `dismissable` see Escape.
|
|
411
|
+
if (event.key === "ArrowLeft") return;
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
if (event.key === "Tab") {
|
|
415
|
+
// Tab out of a menu means "I am done here", not "next item".
|
|
416
|
+
options.onCloseAll();
|
|
417
|
+
return;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
if (inside) seek.handleKey(event);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function onPointerOver(event: Event): void {
|
|
424
|
+
const target = event.target;
|
|
425
|
+
if (!(target instanceof Element)) return;
|
|
426
|
+
const item = target.closest("[data-nebula-item]");
|
|
427
|
+
if (!(item instanceof HTMLElement)) return;
|
|
428
|
+
if (item.hasAttribute("data-disabled")) return;
|
|
429
|
+
|
|
430
|
+
focusSilently(item);
|
|
431
|
+
// Hover focuses the row directly rather than going through rovingFocus,
|
|
432
|
+
// so its `onFocusChange` never runs — the close has to be explicit here
|
|
433
|
+
// or the pointer leaves a trail of open panels behind it.
|
|
434
|
+
if (item.getAttribute("data-submenu") !== null) openSubmenu(item);
|
|
435
|
+
else closeSubmenusFrom(0);
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
panel.addEventListener("keydown", onKeyDown);
|
|
439
|
+
panel.addEventListener("pointerover", onPointerOver);
|
|
440
|
+
|
|
441
|
+
if (options.autoFocusFirst === true) {
|
|
442
|
+
const first = group.items()[0];
|
|
443
|
+
if (first !== undefined) focusSilently(first);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return (): void => {
|
|
447
|
+
closeSubmenusFrom(0);
|
|
448
|
+
panel.removeEventListener("keydown", onKeyDown);
|
|
449
|
+
panel.removeEventListener("pointerover", onPointerOver);
|
|
450
|
+
seek.reset();
|
|
451
|
+
group.destroy();
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Move each submenu's entries from the build-time map onto its element. */
|
|
456
|
+
function claimSubmenuEntries(panel: HTMLElement): void {
|
|
457
|
+
for (const node of panel.querySelectorAll("[data-submenu]")) {
|
|
458
|
+
const id = node.getAttribute("data-submenu");
|
|
459
|
+
if (id === null) continue;
|
|
460
|
+
const entries = pendingSubmenus.get(id);
|
|
461
|
+
if (entries === undefined) continue;
|
|
462
|
+
submenuEntries.set(node, entries);
|
|
463
|
+
pendingSubmenus.delete(id);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ─── entry guards ────────────────────────────────────────────────────
|
|
468
|
+
//
|
|
469
|
+
// Discriminating on an optional `type` needs real guards: `entry.type ===
|
|
470
|
+
// "item"` is false for a plain action, which leaves it undiscriminated. Each
|
|
471
|
+
// guard names the shape it accepts so the renderer narrows without a cast.
|
|
472
|
+
|
|
473
|
+
function isSeparator(entry: MenuEntry): entry is MenuSeparator {
|
|
474
|
+
return entry.type === "separator";
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
function isLabel(entry: MenuEntry): entry is MenuLabel {
|
|
478
|
+
return entry.type === "label";
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function isCheckbox(entry: MenuEntry): entry is MenuCheckbox {
|
|
482
|
+
return entry.type === "checkbox";
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function isRadioGroup(entry: MenuEntry): entry is MenuRadioGroup {
|
|
486
|
+
return entry.type === "radio-group";
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function isSubmenu(entry: MenuEntry): entry is MenuSubmenu {
|
|
490
|
+
return entry.type === "submenu";
|
|
491
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Modal surface — the lifecycle of a dialog-shaped overlay.
|
|
4
|
+
*
|
|
5
|
+
* Dialog, AlertDialog, Sheet and Drawer differ only in where they come from
|
|
6
|
+
* and how they animate. What they share is the modal contract, and every part
|
|
7
|
+
* of it is a thing an app gets wrong when it builds one by hand:
|
|
8
|
+
*
|
|
9
|
+
* - A backdrop, so the page behind reads as unavailable.
|
|
10
|
+
* - Focus trapped inside, and returned to the trigger on close.
|
|
11
|
+
* - Page scroll locked, without the sideways jump when the scrollbar goes.
|
|
12
|
+
* - Escape closes; a click on the backdrop closes, unless the surface is
|
|
13
|
+
* asking a question that must be answered (AlertDialog).
|
|
14
|
+
* - The rest of the page hidden from assistive technology, so a screen reader
|
|
15
|
+
* cannot wander out of the dialog while sighted users cannot.
|
|
16
|
+
*
|
|
17
|
+
* That last one is the one almost always missed. Trapping *keyboard* focus
|
|
18
|
+
* does nothing for a reader navigating by heading or landmark — it will read
|
|
19
|
+
* straight through the page behind the overlay. `aria-hidden` on the siblings
|
|
20
|
+
* of the portal is what actually makes the dialog modal.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { effect, onMount, type TemplateResult } from "@c9up/aurora";
|
|
24
|
+
import {
|
|
25
|
+
type DismissableLayer,
|
|
26
|
+
type DismissReason,
|
|
27
|
+
dismissable,
|
|
28
|
+
} from "./dismissable.js";
|
|
29
|
+
import { type FocusTrap, focusTrap } from "./focusTrap.js";
|
|
30
|
+
import { type Portal, portal } from "./portal.js";
|
|
31
|
+
import { onExitFinished } from "./presence.js";
|
|
32
|
+
import { lockScroll } from "./scrollLock.js";
|
|
33
|
+
|
|
34
|
+
export interface ModalSurfaceOptions {
|
|
35
|
+
open: () => boolean;
|
|
36
|
+
onClose: (reason: DismissReason) => void;
|
|
37
|
+
/** The whole overlay: backdrop and panel. */
|
|
38
|
+
content: () => TemplateResult;
|
|
39
|
+
/**
|
|
40
|
+
* The panel within the content, when the content's root is a full-screen
|
|
41
|
+
* wrapper.
|
|
42
|
+
*
|
|
43
|
+
* Containment is answered against this, not the root. An overlay's root
|
|
44
|
+
* usually spans the viewport so the backdrop can, and a root-based check
|
|
45
|
+
* would report every click on the page as "inside" — outside-click dismissal
|
|
46
|
+
* would silently never fire. The focus trap needs the same distinction:
|
|
47
|
+
* trapping to the root would let Tab reach the backdrop.
|
|
48
|
+
*
|
|
49
|
+
* Defaults to the root, which is right for an overlay that is only a panel.
|
|
50
|
+
*/
|
|
51
|
+
panel?: (root: HTMLElement) => HTMLElement | null;
|
|
52
|
+
/** Where focus lands. Defaults to the first focusable element. */
|
|
53
|
+
initialFocus?: (content: HTMLElement) => HTMLElement | null;
|
|
54
|
+
/** Where focus returns. Defaults to whatever was focused before opening. */
|
|
55
|
+
returnFocus?: () => HTMLElement | null;
|
|
56
|
+
/** A click outside closes. `false` for a decision that must be made. */
|
|
57
|
+
dismissOnOutside?: boolean;
|
|
58
|
+
/** Escape closes. `false` for the same reason. */
|
|
59
|
+
dismissOnEscape?: boolean;
|
|
60
|
+
/** Hold page scroll. Default `true`. */
|
|
61
|
+
lockScroll?: boolean;
|
|
62
|
+
onOpened?: (content: HTMLElement) => void;
|
|
63
|
+
onClosed?: () => void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface Live {
|
|
67
|
+
readonly mount: Portal;
|
|
68
|
+
/** Carries `data-state`; the exit animation is watched here. */
|
|
69
|
+
readonly element: HTMLElement;
|
|
70
|
+
readonly layer: DismissableLayer;
|
|
71
|
+
readonly trap: FocusTrap;
|
|
72
|
+
readonly unlock: () => void;
|
|
73
|
+
readonly restoreSiblings: () => void;
|
|
74
|
+
cancelExit: (() => void) | null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function modalSurface(options: ModalSurfaceOptions): void {
|
|
78
|
+
let live: Live | null = null;
|
|
79
|
+
|
|
80
|
+
function show(): void {
|
|
81
|
+
if (live !== null) {
|
|
82
|
+
live.cancelExit?.();
|
|
83
|
+
live.cancelExit = null;
|
|
84
|
+
live.element.setAttribute("data-state", "open");
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const mount = portal(options.content());
|
|
89
|
+
const element = mount.host.firstElementChild;
|
|
90
|
+
if (!(element instanceof HTMLElement)) {
|
|
91
|
+
mount.close();
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
element.setAttribute("data-state", "open");
|
|
95
|
+
|
|
96
|
+
const panel = options.panel?.(element) ?? element;
|
|
97
|
+
const unlock = options.lockScroll === false ? () => {} : lockScroll();
|
|
98
|
+
const restoreSiblings = hideSiblingsFrom(mount.host);
|
|
99
|
+
|
|
100
|
+
const trap = focusTrap(panel, {
|
|
101
|
+
initialFocus: () => options.initialFocus?.(panel) ?? null,
|
|
102
|
+
returnFocus: options.returnFocus,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
const layer = dismissable({
|
|
106
|
+
element: () => panel,
|
|
107
|
+
onDismiss: options.onClose,
|
|
108
|
+
escapeKey: options.dismissOnEscape !== false,
|
|
109
|
+
outsidePointer: options.dismissOnOutside !== false,
|
|
110
|
+
outsideFocus: false,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
live = {
|
|
114
|
+
mount,
|
|
115
|
+
element,
|
|
116
|
+
layer,
|
|
117
|
+
trap,
|
|
118
|
+
unlock,
|
|
119
|
+
restoreSiblings,
|
|
120
|
+
cancelExit: null,
|
|
121
|
+
};
|
|
122
|
+
options.onOpened?.(panel);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function hide(): void {
|
|
126
|
+
const current = live;
|
|
127
|
+
if (current === null) return;
|
|
128
|
+
|
|
129
|
+
current.layer.remove();
|
|
130
|
+
current.trap.release();
|
|
131
|
+
current.restoreSiblings();
|
|
132
|
+
current.element.setAttribute("data-state", "closed");
|
|
133
|
+
options.onClosed?.();
|
|
134
|
+
|
|
135
|
+
// Scroll stays locked through the exit animation. Releasing it first
|
|
136
|
+
// lets the page jump back under a panel that is still sliding away.
|
|
137
|
+
current.cancelExit = onExitFinished(current.element, () => {
|
|
138
|
+
current.unlock();
|
|
139
|
+
current.mount.close();
|
|
140
|
+
if (live === current) live = null;
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function teardown(): void {
|
|
145
|
+
const current = live;
|
|
146
|
+
if (current === null) return;
|
|
147
|
+
live = null;
|
|
148
|
+
current.cancelExit?.();
|
|
149
|
+
current.layer.remove();
|
|
150
|
+
current.trap.release();
|
|
151
|
+
current.restoreSiblings();
|
|
152
|
+
current.unlock();
|
|
153
|
+
current.mount.close();
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
onMount(() => {
|
|
157
|
+
const stop = effect(() => {
|
|
158
|
+
if (options.open()) show();
|
|
159
|
+
else hide();
|
|
160
|
+
});
|
|
161
|
+
return () => {
|
|
162
|
+
stop();
|
|
163
|
+
teardown();
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Hide everything except the overlay from assistive technology.
|
|
170
|
+
*
|
|
171
|
+
* Marks each sibling of the portal host, and restores exactly what was there
|
|
172
|
+
* before — an element that was *already* `aria-hidden` for its own reasons
|
|
173
|
+
* must stay that way after the dialog closes, so the previous value is saved
|
|
174
|
+
* rather than the attribute simply removed.
|
|
175
|
+
*
|
|
176
|
+
* Only siblings of the host, not the whole page: a second modal opening over
|
|
177
|
+
* the first must not hide it, and it will not, because it appends its own host
|
|
178
|
+
* after the first one and only touches what is beside it.
|
|
179
|
+
*/
|
|
180
|
+
function hideSiblingsFrom(host: HTMLElement): () => void {
|
|
181
|
+
const parent = host.parentElement;
|
|
182
|
+
if (parent === null) return () => {};
|
|
183
|
+
|
|
184
|
+
const saved: Array<[Element, string | null]> = [];
|
|
185
|
+
for (const sibling of parent.children) {
|
|
186
|
+
if (sibling === host) continue;
|
|
187
|
+
// A live region announcing outside the dialog (a toast) is deliberate.
|
|
188
|
+
if (sibling.hasAttribute("data-nebula-live")) continue;
|
|
189
|
+
saved.push([sibling, sibling.getAttribute("aria-hidden")]);
|
|
190
|
+
sibling.setAttribute("aria-hidden", "true");
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return (): void => {
|
|
194
|
+
for (const [element, previous] of saved) {
|
|
195
|
+
if (previous === null) element.removeAttribute("aria-hidden");
|
|
196
|
+
else element.setAttribute("aria-hidden", previous);
|
|
197
|
+
}
|
|
198
|
+
};
|
|
199
|
+
}
|