@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,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which elements can take focus.
|
|
3
|
+
*
|
|
4
|
+
* The focus trap, the roving-focus groups and every overlay that restores
|
|
5
|
+
* focus on close all need the same answer, and getting it slightly different
|
|
6
|
+
* in each place is how keyboard navigation rots. One implementation here.
|
|
7
|
+
*
|
|
8
|
+
* The selector is the standard tabbable set, but a selector alone is not the
|
|
9
|
+
* answer: it matches nodes that are disabled, `inert`, hidden by CSS, or
|
|
10
|
+
* inside a collapsed `<details>`. Those have to be filtered by asking the
|
|
11
|
+
* layout engine, which is why `isVisible` reads `offsetParent` and computed
|
|
12
|
+
* styles rather than trusting the markup.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Is the element rendered and interactive right now?
|
|
16
|
+
*
|
|
17
|
+
* `offsetParent === null` catches `display: none` on the element or any
|
|
18
|
+
* ancestor in one property read, which is much cheaper than walking the tree.
|
|
19
|
+
* It reports null for `position: fixed` elements too, so those fall through to
|
|
20
|
+
* the explicit style checks — that branch is why dialogs and popovers, which
|
|
21
|
+
* are routinely fixed, are not wrongly treated as hidden.
|
|
22
|
+
*/
|
|
23
|
+
export declare function isVisible(el: HTMLElement): boolean;
|
|
24
|
+
/**
|
|
25
|
+
* Can this element receive focus via the keyboard?
|
|
26
|
+
*
|
|
27
|
+
* Focusability is decided from the selector match plus the `tabindex`
|
|
28
|
+
* *attribute* — deliberately not the `tabIndex` property. The property is
|
|
29
|
+
* derived: a browser reports `0` for a native `<button>` that never declared
|
|
30
|
+
* one, and DOM implementations disagree about which elements get that
|
|
31
|
+
* treatment. Reading the attribute asks what the markup actually says, and
|
|
32
|
+
* "natively focusable unless it opted out" is then a rule this function states
|
|
33
|
+
* rather than one it inherits from whichever DOM it is running in.
|
|
34
|
+
*/
|
|
35
|
+
export declare function isFocusable(el: Element): el is HTMLElement;
|
|
36
|
+
/**
|
|
37
|
+
* Every focusable descendant of `root`, in tab order.
|
|
38
|
+
*
|
|
39
|
+
* Document order is returned as-is. Honouring positive `tabindex` values would
|
|
40
|
+
* mean a second sort, and nebula never emits one — a positive tabindex breaks
|
|
41
|
+
* the tab order of the whole page, so the library treats its presence in host
|
|
42
|
+
* markup as the host's problem rather than reordering around it.
|
|
43
|
+
*/
|
|
44
|
+
export declare function focusableWithin(root: ParentNode): HTMLElement[];
|
|
45
|
+
/** First focusable descendant, or `null` when the subtree has none. */
|
|
46
|
+
export declare function firstFocusable(root: ParentNode): HTMLElement | null;
|
|
47
|
+
/**
|
|
48
|
+
* Focus an element without scrolling the page to it.
|
|
49
|
+
*
|
|
50
|
+
* Overlays position themselves; letting the browser scroll to the newly
|
|
51
|
+
* focused node fights that positioning and visibly jumps the page.
|
|
52
|
+
*/
|
|
53
|
+
export declare function focusSilently(el: HTMLElement | null | undefined): void;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Which elements can take focus.
|
|
4
|
+
*
|
|
5
|
+
* The focus trap, the roving-focus groups and every overlay that restores
|
|
6
|
+
* focus on close all need the same answer, and getting it slightly different
|
|
7
|
+
* in each place is how keyboard navigation rots. One implementation here.
|
|
8
|
+
*
|
|
9
|
+
* The selector is the standard tabbable set, but a selector alone is not the
|
|
10
|
+
* answer: it matches nodes that are disabled, `inert`, hidden by CSS, or
|
|
11
|
+
* inside a collapsed `<details>`. Those have to be filtered by asking the
|
|
12
|
+
* layout engine, which is why `isVisible` reads `offsetParent` and computed
|
|
13
|
+
* styles rather than trusting the markup.
|
|
14
|
+
*/
|
|
15
|
+
const FOCUSABLE_SELECTOR = [
|
|
16
|
+
"a[href]",
|
|
17
|
+
"area[href]",
|
|
18
|
+
"button:not([disabled])",
|
|
19
|
+
"input:not([disabled]):not([type='hidden'])",
|
|
20
|
+
"select:not([disabled])",
|
|
21
|
+
"textarea:not([disabled])",
|
|
22
|
+
"iframe",
|
|
23
|
+
"object",
|
|
24
|
+
"embed",
|
|
25
|
+
"audio[controls]",
|
|
26
|
+
"video[controls]",
|
|
27
|
+
"summary",
|
|
28
|
+
"[contenteditable]:not([contenteditable='false'])",
|
|
29
|
+
"[tabindex]",
|
|
30
|
+
].join(",");
|
|
31
|
+
/**
|
|
32
|
+
* Is the element rendered and interactive right now?
|
|
33
|
+
*
|
|
34
|
+
* `offsetParent === null` catches `display: none` on the element or any
|
|
35
|
+
* ancestor in one property read, which is much cheaper than walking the tree.
|
|
36
|
+
* It reports null for `position: fixed` elements too, so those fall through to
|
|
37
|
+
* the explicit style checks — that branch is why dialogs and popovers, which
|
|
38
|
+
* are routinely fixed, are not wrongly treated as hidden.
|
|
39
|
+
*/
|
|
40
|
+
export function isVisible(el) {
|
|
41
|
+
if (el.hasAttribute("inert"))
|
|
42
|
+
return false;
|
|
43
|
+
if (el.closest("[inert]") !== null)
|
|
44
|
+
return false;
|
|
45
|
+
if (el.offsetParent !== null)
|
|
46
|
+
return true;
|
|
47
|
+
const style = getComputedStyle(el);
|
|
48
|
+
if (style.position !== "fixed")
|
|
49
|
+
return false;
|
|
50
|
+
return style.display !== "none" && style.visibility !== "hidden";
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Can this element receive focus via the keyboard?
|
|
54
|
+
*
|
|
55
|
+
* Focusability is decided from the selector match plus the `tabindex`
|
|
56
|
+
* *attribute* — deliberately not the `tabIndex` property. The property is
|
|
57
|
+
* derived: a browser reports `0` for a native `<button>` that never declared
|
|
58
|
+
* one, and DOM implementations disagree about which elements get that
|
|
59
|
+
* treatment. Reading the attribute asks what the markup actually says, and
|
|
60
|
+
* "natively focusable unless it opted out" is then a rule this function states
|
|
61
|
+
* rather than one it inherits from whichever DOM it is running in.
|
|
62
|
+
*/
|
|
63
|
+
export function isFocusable(el) {
|
|
64
|
+
if (!(el instanceof HTMLElement))
|
|
65
|
+
return false;
|
|
66
|
+
if (!el.matches(FOCUSABLE_SELECTOR))
|
|
67
|
+
return false;
|
|
68
|
+
if (el.hasAttribute("disabled"))
|
|
69
|
+
return false;
|
|
70
|
+
if (el.getAttribute("aria-hidden") === "true")
|
|
71
|
+
return false;
|
|
72
|
+
const declared = el.getAttribute("tabindex");
|
|
73
|
+
if (declared !== null && Number.parseInt(declared, 10) < 0)
|
|
74
|
+
return false;
|
|
75
|
+
return isVisible(el);
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Every focusable descendant of `root`, in tab order.
|
|
79
|
+
*
|
|
80
|
+
* Document order is returned as-is. Honouring positive `tabindex` values would
|
|
81
|
+
* mean a second sort, and nebula never emits one — a positive tabindex breaks
|
|
82
|
+
* the tab order of the whole page, so the library treats its presence in host
|
|
83
|
+
* markup as the host's problem rather than reordering around it.
|
|
84
|
+
*/
|
|
85
|
+
export function focusableWithin(root) {
|
|
86
|
+
const found = [];
|
|
87
|
+
for (const candidate of root.querySelectorAll(FOCUSABLE_SELECTOR)) {
|
|
88
|
+
if (isFocusable(candidate))
|
|
89
|
+
found.push(candidate);
|
|
90
|
+
}
|
|
91
|
+
return found;
|
|
92
|
+
}
|
|
93
|
+
/** First focusable descendant, or `null` when the subtree has none. */
|
|
94
|
+
export function firstFocusable(root) {
|
|
95
|
+
for (const candidate of root.querySelectorAll(FOCUSABLE_SELECTOR)) {
|
|
96
|
+
if (isFocusable(candidate))
|
|
97
|
+
return candidate;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Focus an element without scrolling the page to it.
|
|
103
|
+
*
|
|
104
|
+
* Overlays position themselves; letting the browser scroll to the newly
|
|
105
|
+
* focused node fights that positioning and visibly jumps the page.
|
|
106
|
+
*/
|
|
107
|
+
export function focusSilently(el) {
|
|
108
|
+
el?.focus({ preventScroll: true });
|
|
109
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless primitives — the behaviour layer nebula owns instead of Radix.
|
|
3
|
+
*
|
|
4
|
+
* shadcn/ui is styling on top of Radix. Radix is React-only, so porting the
|
|
5
|
+
* styling alone would leave every interactive component inert. These modules
|
|
6
|
+
* are that missing half, written against Aurora's signals and DOM: focus
|
|
7
|
+
* management, layer dismissal, placement, presence, roving focus, type-ahead
|
|
8
|
+
* and controlled/uncontrolled state.
|
|
9
|
+
*
|
|
10
|
+
* They are deliberately usable on their own. A component the registry does not
|
|
11
|
+
* ship — an app's own combobox, a bespoke overlay — should be able to reach
|
|
12
|
+
* for the same trap and the same layer stack rather than growing a second,
|
|
13
|
+
* subtly different implementation next door.
|
|
14
|
+
*/
|
|
15
|
+
export { type Controllable, type ControllableOptions, controllable, } from "./controllable.js";
|
|
16
|
+
export { type DismissableLayer, type DismissableOptions, type DismissReason, dismissable, layerCount, } from "./dismissable.js";
|
|
17
|
+
export { type Align, type AutoPosition, type AutoPositionOptions, autoPosition, type Placement, type Position, type PositionOptions, type Rect, resolvePosition, type Side, type Viewport, } from "./floating.js";
|
|
18
|
+
export { type FloatingSurfaceOptions, floatingSurface, } from "./floatingSurface.js";
|
|
19
|
+
export { firstFocusable, focusableWithin, focusSilently, isFocusable, isVisible, } from "./focusable.js";
|
|
20
|
+
export { type FocusTrap, type FocusTrapOptions, focusTrap, } from "./focusTrap.js";
|
|
21
|
+
export { type MenuAction, type MenuCheckbox, type MenuEntry, type MenuLabel, type MenuRadioGroup, type MenuSeparator, type MenuSubmenu, menuPanel, menuPanelClasses, wireMenu, } from "./menu.js";
|
|
22
|
+
export { type ModalSurfaceOptions, modalSurface } from "./modalSurface.js";
|
|
23
|
+
export { type Portal, type PortalOptions, portal } from "./portal.js";
|
|
24
|
+
export { onExitFinished, type Presence, type PresenceState, presence, } from "./presence.js";
|
|
25
|
+
export { type Orientation, type RovingFocus, type RovingFocusOptions, rovingFocus, } from "./rovingFocus.js";
|
|
26
|
+
export { isScrollLocked, lockScroll } from "./scrollLock.js";
|
|
27
|
+
export { type Typeahead, type TypeaheadOptions, typeahead, } from "./typeahead.js";
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless primitives — the behaviour layer nebula owns instead of Radix.
|
|
3
|
+
*
|
|
4
|
+
* shadcn/ui is styling on top of Radix. Radix is React-only, so porting the
|
|
5
|
+
* styling alone would leave every interactive component inert. These modules
|
|
6
|
+
* are that missing half, written against Aurora's signals and DOM: focus
|
|
7
|
+
* management, layer dismissal, placement, presence, roving focus, type-ahead
|
|
8
|
+
* and controlled/uncontrolled state.
|
|
9
|
+
*
|
|
10
|
+
* They are deliberately usable on their own. A component the registry does not
|
|
11
|
+
* ship — an app's own combobox, a bespoke overlay — should be able to reach
|
|
12
|
+
* for the same trap and the same layer stack rather than growing a second,
|
|
13
|
+
* subtly different implementation next door.
|
|
14
|
+
*/
|
|
15
|
+
export { controllable, } from "./controllable.js";
|
|
16
|
+
export { dismissable, layerCount, } from "./dismissable.js";
|
|
17
|
+
export { autoPosition, resolvePosition, } from "./floating.js";
|
|
18
|
+
export { floatingSurface, } from "./floatingSurface.js";
|
|
19
|
+
export { firstFocusable, focusableWithin, focusSilently, isFocusable, isVisible, } from "./focusable.js";
|
|
20
|
+
export { focusTrap, } from "./focusTrap.js";
|
|
21
|
+
export { menuPanel, menuPanelClasses, wireMenu, } from "./menu.js";
|
|
22
|
+
export { modalSurface } from "./modalSurface.js";
|
|
23
|
+
export { portal } from "./portal.js";
|
|
24
|
+
export { onExitFinished, presence, } from "./presence.js";
|
|
25
|
+
export { rovingFocus, } from "./rovingFocus.js";
|
|
26
|
+
export { isScrollLocked, lockScroll } from "./scrollLock.js";
|
|
27
|
+
export { typeahead, } from "./typeahead.js";
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The menu panel, shared by every menu-shaped surface.
|
|
3
|
+
*
|
|
4
|
+
* DropdownMenu, ContextMenu and Menubar differ only in what opens them — a
|
|
5
|
+
* button, a right-click, a bar of buttons. The panel itself, its entries, its
|
|
6
|
+
* keyboard model and its submenus are identical, and live here.
|
|
7
|
+
*
|
|
8
|
+
* **Entries are data.** A menu is a list, and describing it as one is what
|
|
9
|
+
* lets the panel enforce things no individual item can see: which radio in a
|
|
10
|
+
* group is selected, where the separators fall, and — the one that matters
|
|
11
|
+
* most — that activating any item closes the whole stack, submenus included.
|
|
12
|
+
*
|
|
13
|
+
* **Submenus are imperative.** `floatingSurface` registers mount and unmount
|
|
14
|
+
* hooks, so it can only be called from a component setup. A submenu opens from
|
|
15
|
+
* inside portalled content, long after setup has run, so it wires `portal`,
|
|
16
|
+
* `autoPosition` and `dismissable` directly. The stack of open submenus is
|
|
17
|
+
* held here and unwound together.
|
|
18
|
+
*
|
|
19
|
+
* The keyboard model is the WAI-ARIA menu pattern: arrows move, Home and End
|
|
20
|
+
* jump, typing seeks, ArrowRight opens a submenu, ArrowLeft closes it, Escape
|
|
21
|
+
* closes one level, and activating an item closes everything.
|
|
22
|
+
*/
|
|
23
|
+
import { type TemplateResult } from "@c9up/aurora";
|
|
24
|
+
import type { Child } from "../lib/children.js";
|
|
25
|
+
export interface MenuAction {
|
|
26
|
+
type?: "item";
|
|
27
|
+
label: Child;
|
|
28
|
+
icon?: Child;
|
|
29
|
+
/** Rendered right-aligned. Display only — bind the real shortcut yourself. */
|
|
30
|
+
shortcut?: string;
|
|
31
|
+
disabled?: boolean;
|
|
32
|
+
/** Styles the entry as a destructive action. */
|
|
33
|
+
destructive?: boolean;
|
|
34
|
+
onSelect?: () => void;
|
|
35
|
+
}
|
|
36
|
+
export interface MenuCheckbox {
|
|
37
|
+
type: "checkbox";
|
|
38
|
+
label: Child;
|
|
39
|
+
checked: boolean;
|
|
40
|
+
disabled?: boolean;
|
|
41
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
42
|
+
}
|
|
43
|
+
export interface MenuRadioGroup {
|
|
44
|
+
type: "radio-group";
|
|
45
|
+
value: string;
|
|
46
|
+
options: ReadonlyArray<{
|
|
47
|
+
value: string;
|
|
48
|
+
label: Child;
|
|
49
|
+
disabled?: boolean;
|
|
50
|
+
}>;
|
|
51
|
+
onValueChange?: (value: string) => void;
|
|
52
|
+
}
|
|
53
|
+
export interface MenuLabel {
|
|
54
|
+
type: "label";
|
|
55
|
+
label: Child;
|
|
56
|
+
}
|
|
57
|
+
export interface MenuSeparator {
|
|
58
|
+
type: "separator";
|
|
59
|
+
}
|
|
60
|
+
export interface MenuSubmenu {
|
|
61
|
+
type: "submenu";
|
|
62
|
+
label: Child;
|
|
63
|
+
icon?: Child;
|
|
64
|
+
disabled?: boolean;
|
|
65
|
+
entries: readonly MenuEntry[];
|
|
66
|
+
}
|
|
67
|
+
export type MenuEntry = MenuAction | MenuCheckbox | MenuRadioGroup | MenuLabel | MenuSeparator | MenuSubmenu;
|
|
68
|
+
export declare const menuPanelClasses = "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";
|
|
69
|
+
export interface MenuPanelOptions {
|
|
70
|
+
id: string;
|
|
71
|
+
entries: readonly MenuEntry[];
|
|
72
|
+
/** Close the whole menu stack — called after an item is activated. */
|
|
73
|
+
onCloseAll: () => void;
|
|
74
|
+
labelledBy?: string;
|
|
75
|
+
class?: string;
|
|
76
|
+
}
|
|
77
|
+
/** The panel's markup. Behaviour is attached separately by `wireMenu`. */
|
|
78
|
+
export declare function menuPanel(options: MenuPanelOptions): TemplateResult;
|
|
79
|
+
export interface WireMenuOptions {
|
|
80
|
+
/** Close the whole stack, back to the original trigger. */
|
|
81
|
+
onCloseAll: () => void;
|
|
82
|
+
/** Focus the first item as soon as the panel opens. */
|
|
83
|
+
autoFocusFirst?: boolean;
|
|
84
|
+
/**
|
|
85
|
+
* Close *this* panel and return focus to whatever opened it.
|
|
86
|
+
*
|
|
87
|
+
* Supplied only when wiring a submenu, and it is what makes ArrowLeft and
|
|
88
|
+
* Escape work inside one. A submenu is portalled, so its keydowns never
|
|
89
|
+
* bubble to the parent panel's listener — the parent hands down a way to be
|
|
90
|
+
* called instead. Without it the two keys fall through: ArrowLeft does
|
|
91
|
+
* nothing at all, and Escape reaches the document and closes the entire
|
|
92
|
+
* stack rather than one level.
|
|
93
|
+
*/
|
|
94
|
+
onCloseSelf?: () => void;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Attach the keyboard model and submenu handling to a live panel.
|
|
98
|
+
*
|
|
99
|
+
* Returns a teardown that also closes any submenus still open, which is what
|
|
100
|
+
* keeps a stack of three from leaving two behind when the root closes.
|
|
101
|
+
*/
|
|
102
|
+
export declare function wireMenu(panel: HTMLElement, options: WireMenuOptions): () => void;
|
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
import { html } from "@c9up/aurora";
|
|
25
|
+
import { cn } from "../lib/cn.js";
|
|
26
|
+
import { CheckIcon, ChevronRightIcon, DotIcon } from "../lib/icons.js";
|
|
27
|
+
import { uid } from "../lib/id.js";
|
|
28
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
29
|
+
import { dismissable } from "./dismissable.js";
|
|
30
|
+
import { autoPosition } from "./floating.js";
|
|
31
|
+
import { focusSilently } from "./focusable.js";
|
|
32
|
+
import { portal } from "./portal.js";
|
|
33
|
+
import { rovingFocus } from "./rovingFocus.js";
|
|
34
|
+
import { typeahead } from "./typeahead.js";
|
|
35
|
+
// ─── classes ─────────────────────────────────────────────────────────
|
|
36
|
+
export const menuPanelClasses = "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";
|
|
37
|
+
const itemClasses = "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";
|
|
38
|
+
const destructiveClasses = "text-destructive focus:bg-destructive/10 focus:text-destructive [&_svg]:text-destructive";
|
|
39
|
+
const indentedItemClasses = `${itemClasses} py-1.5 pr-2 pl-8`;
|
|
40
|
+
/** The panel's markup. Behaviour is attached separately by `wireMenu`. */
|
|
41
|
+
export function menuPanel(options) {
|
|
42
|
+
return html `<div
|
|
43
|
+
data-slot="menu-content"
|
|
44
|
+
id="${options.id}"
|
|
45
|
+
role="menu"
|
|
46
|
+
aria-labelledby="${options.labelledBy}"
|
|
47
|
+
aria-orientation="vertical"
|
|
48
|
+
tabindex="-1"
|
|
49
|
+
class="${cn(menuPanelClasses, zoomInOut, options.class)}"
|
|
50
|
+
>${options.entries.map((entry) => renderEntry(entry, options.onCloseAll))}</div>`;
|
|
51
|
+
}
|
|
52
|
+
function renderEntry(entry, closeAll) {
|
|
53
|
+
if (isSeparator(entry)) {
|
|
54
|
+
return html `<div role="separator" class="bg-border -mx-1 my-1 h-px"></div>`;
|
|
55
|
+
}
|
|
56
|
+
if (isLabel(entry)) {
|
|
57
|
+
return html `<div
|
|
58
|
+
role="presentation"
|
|
59
|
+
class="px-2 py-1.5 text-sm font-medium text-muted-foreground"
|
|
60
|
+
>${entry.label}</div>`;
|
|
61
|
+
}
|
|
62
|
+
if (isCheckbox(entry))
|
|
63
|
+
return renderCheckbox(entry, closeAll);
|
|
64
|
+
if (isRadioGroup(entry))
|
|
65
|
+
return renderRadioGroup(entry, closeAll);
|
|
66
|
+
if (isSubmenu(entry))
|
|
67
|
+
return renderSubmenuTrigger(entry);
|
|
68
|
+
return renderAction(entry, closeAll);
|
|
69
|
+
}
|
|
70
|
+
function renderAction(entry, closeAll) {
|
|
71
|
+
return html `<div
|
|
72
|
+
role="menuitem"
|
|
73
|
+
data-nebula-item
|
|
74
|
+
data-disabled="${entry.disabled === true ? "" : undefined}"
|
|
75
|
+
aria-disabled="${entry.disabled === true ? "true" : undefined}"
|
|
76
|
+
tabindex="-1"
|
|
77
|
+
class="${cn(itemClasses, entry.destructive === true ? destructiveClasses : "")}"
|
|
78
|
+
@click="${() => activate(entry.disabled, entry.onSelect, closeAll)}"
|
|
79
|
+
>
|
|
80
|
+
${entry.icon}
|
|
81
|
+
<span class="flex-1 truncate">${entry.label}</span>
|
|
82
|
+
${entry.shortcut === undefined
|
|
83
|
+
? null
|
|
84
|
+
: html `<span class="text-muted-foreground ml-auto text-xs tracking-widest"
|
|
85
|
+
>${entry.shortcut}</span
|
|
86
|
+
>`}
|
|
87
|
+
</div>`;
|
|
88
|
+
}
|
|
89
|
+
function renderCheckbox(entry, closeAll) {
|
|
90
|
+
return html `<div
|
|
91
|
+
role="menuitemcheckbox"
|
|
92
|
+
data-nebula-item
|
|
93
|
+
aria-checked="${entry.checked ? "true" : "false"}"
|
|
94
|
+
data-disabled="${entry.disabled === true ? "" : undefined}"
|
|
95
|
+
tabindex="-1"
|
|
96
|
+
class="${indentedItemClasses}"
|
|
97
|
+
@click="${() => activate(entry.disabled, () => entry.onCheckedChange?.(!entry.checked), closeAll)}"
|
|
98
|
+
>
|
|
99
|
+
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
100
|
+
${entry.checked ? CheckIcon({ class: "size-4" }) : null}
|
|
101
|
+
</span>
|
|
102
|
+
${entry.label}
|
|
103
|
+
</div>`;
|
|
104
|
+
}
|
|
105
|
+
function renderRadioGroup(entry, closeAll) {
|
|
106
|
+
return html `<div role="group">
|
|
107
|
+
${entry.options.map((option) => html `<div
|
|
108
|
+
role="menuitemradio"
|
|
109
|
+
data-nebula-item
|
|
110
|
+
aria-checked="${option.value === entry.value ? "true" : "false"}"
|
|
111
|
+
data-disabled="${option.disabled === true ? "" : undefined}"
|
|
112
|
+
tabindex="-1"
|
|
113
|
+
class="${indentedItemClasses}"
|
|
114
|
+
@click="${() => activate(option.disabled, () => entry.onValueChange?.(option.value), closeAll)}"
|
|
115
|
+
>
|
|
116
|
+
<span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
|
117
|
+
${option.value === entry.value ? DotIcon({ class: "size-2" }) : null}
|
|
118
|
+
</span>
|
|
119
|
+
${option.label}
|
|
120
|
+
</div>`)}
|
|
121
|
+
</div>`;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* A submenu's trigger row.
|
|
125
|
+
*
|
|
126
|
+
* The entries are stashed on the element through a WeakMap rather than an
|
|
127
|
+
* attribute — they contain callbacks and nested structures, and serialising
|
|
128
|
+
* those into the DOM is not possible. `wireMenu` reads them back when the row
|
|
129
|
+
* is opened.
|
|
130
|
+
*/
|
|
131
|
+
const submenuEntries = new WeakMap();
|
|
132
|
+
function renderSubmenuTrigger(entry) {
|
|
133
|
+
const id = uid("menu-sub-trigger");
|
|
134
|
+
const row = html `<div
|
|
135
|
+
role="menuitem"
|
|
136
|
+
data-nebula-item
|
|
137
|
+
data-submenu="${id}"
|
|
138
|
+
data-disabled="${entry.disabled === true ? "" : undefined}"
|
|
139
|
+
aria-haspopup="menu"
|
|
140
|
+
aria-expanded="false"
|
|
141
|
+
tabindex="-1"
|
|
142
|
+
class="${itemClasses}"
|
|
143
|
+
>
|
|
144
|
+
${entry.icon}
|
|
145
|
+
<span class="flex-1 truncate">${entry.label}</span>
|
|
146
|
+
${ChevronRightIcon({ class: "ml-auto size-4" })}
|
|
147
|
+
</div>`;
|
|
148
|
+
pendingSubmenus.set(id, entry.entries);
|
|
149
|
+
return row;
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Entries waiting to be claimed by their element.
|
|
153
|
+
*
|
|
154
|
+
* The trigger's entries are known while the template is built, but the element
|
|
155
|
+
* does not exist until it is mounted. This bridges the two: `wireMenu` moves
|
|
156
|
+
* each set from here onto the real element and clears the id, so nothing is
|
|
157
|
+
* retained after the menu closes.
|
|
158
|
+
*/
|
|
159
|
+
const pendingSubmenus = new Map();
|
|
160
|
+
function activate(disabled, onSelect, closeAll) {
|
|
161
|
+
if (disabled === true)
|
|
162
|
+
return;
|
|
163
|
+
onSelect?.();
|
|
164
|
+
closeAll();
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Attach the keyboard model and submenu handling to a live panel.
|
|
168
|
+
*
|
|
169
|
+
* Returns a teardown that also closes any submenus still open, which is what
|
|
170
|
+
* keeps a stack of three from leaving two behind when the root closes.
|
|
171
|
+
*/
|
|
172
|
+
export function wireMenu(panel, options) {
|
|
173
|
+
claimSubmenuEntries(panel);
|
|
174
|
+
const open = [];
|
|
175
|
+
function closeSubmenusFrom(depth) {
|
|
176
|
+
while (open.length > depth)
|
|
177
|
+
open.pop()?.close();
|
|
178
|
+
}
|
|
179
|
+
const group = rovingFocus({
|
|
180
|
+
container: () => panel,
|
|
181
|
+
itemSelector: "[data-nebula-item]",
|
|
182
|
+
orientation: "vertical",
|
|
183
|
+
// Moving off a submenu's trigger closes it, so the pointer travelling
|
|
184
|
+
// down a menu does not leave a trail of open panels behind it.
|
|
185
|
+
onFocusChange: (item) => {
|
|
186
|
+
if (item.getAttribute("data-submenu") === null)
|
|
187
|
+
closeSubmenusFrom(0);
|
|
188
|
+
},
|
|
189
|
+
onSelect: (item) => {
|
|
190
|
+
if (item.getAttribute("data-submenu") !== null)
|
|
191
|
+
openSubmenu(item);
|
|
192
|
+
else
|
|
193
|
+
item.click();
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
group.sync();
|
|
197
|
+
const seek = typeahead({
|
|
198
|
+
items: () => group.items(),
|
|
199
|
+
onMatch: (item) => focusSilently(item),
|
|
200
|
+
});
|
|
201
|
+
function openSubmenu(trigger) {
|
|
202
|
+
const entries = submenuEntries.get(trigger);
|
|
203
|
+
if (entries === undefined || entries.length === 0)
|
|
204
|
+
return;
|
|
205
|
+
closeSubmenusFrom(0);
|
|
206
|
+
const panelId = uid("menu-sub");
|
|
207
|
+
const mount = portal(menuPanel({ id: panelId, entries, onCloseAll: options.onCloseAll }));
|
|
208
|
+
const element = mount.host.firstElementChild;
|
|
209
|
+
if (!(element instanceof HTMLElement)) {
|
|
210
|
+
mount.close();
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
element.setAttribute("data-state", "open");
|
|
214
|
+
trigger.setAttribute("aria-expanded", "true");
|
|
215
|
+
trigger.setAttribute("aria-controls", panelId);
|
|
216
|
+
// Submenus sit beside their parent, overlapping it slightly, which is what
|
|
217
|
+
// lets the pointer travel diagonally into them without crossing a gap.
|
|
218
|
+
const position = autoPosition(trigger, element, {
|
|
219
|
+
placement: "right-start",
|
|
220
|
+
offset: -4,
|
|
221
|
+
});
|
|
222
|
+
const layer = dismissable({
|
|
223
|
+
element: () => element,
|
|
224
|
+
exclude: () => [trigger, panel],
|
|
225
|
+
onDismiss: () => closeSubmenusFrom(0),
|
|
226
|
+
escapeKey: false,
|
|
227
|
+
});
|
|
228
|
+
const inner = wireMenu(element, {
|
|
229
|
+
...options,
|
|
230
|
+
onCloseSelf: () => {
|
|
231
|
+
closeSubmenusFrom(0);
|
|
232
|
+
focusSilently(trigger);
|
|
233
|
+
},
|
|
234
|
+
});
|
|
235
|
+
const firstItem = element.querySelector("[data-nebula-item]");
|
|
236
|
+
if (firstItem instanceof HTMLElement)
|
|
237
|
+
focusSilently(firstItem);
|
|
238
|
+
open.push({
|
|
239
|
+
close() {
|
|
240
|
+
inner();
|
|
241
|
+
layer.remove();
|
|
242
|
+
position.stop();
|
|
243
|
+
mount.close();
|
|
244
|
+
trigger.setAttribute("aria-expanded", "false");
|
|
245
|
+
trigger.removeAttribute("aria-controls");
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
function onKeyDown(event) {
|
|
250
|
+
const active = document.activeElement;
|
|
251
|
+
const inside = active instanceof Node && panel.contains(active);
|
|
252
|
+
if (event.key === "ArrowRight" && inside) {
|
|
253
|
+
if (!(active instanceof HTMLElement))
|
|
254
|
+
return;
|
|
255
|
+
if (active.getAttribute("data-submenu") === null)
|
|
256
|
+
return;
|
|
257
|
+
event.preventDefault();
|
|
258
|
+
event.stopPropagation();
|
|
259
|
+
openSubmenu(active);
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
// One level per press for both keys: a submenu three deep takes three
|
|
263
|
+
// Escapes, not one. Closing a child we opened comes first; with none
|
|
264
|
+
// open, this panel is itself the child and asks its parent to close it.
|
|
265
|
+
// `dismissable` handles the last level, closing the root menu.
|
|
266
|
+
if (event.key === "ArrowLeft" || event.key === "Escape") {
|
|
267
|
+
if (open.length > 0) {
|
|
268
|
+
event.preventDefault();
|
|
269
|
+
event.stopPropagation();
|
|
270
|
+
closeSubmenusFrom(open.length - 1);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
if (options.onCloseSelf !== undefined) {
|
|
274
|
+
event.preventDefault();
|
|
275
|
+
event.stopPropagation();
|
|
276
|
+
options.onCloseSelf();
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
// A root menu ignores ArrowLeft and lets `dismissable` see Escape.
|
|
280
|
+
if (event.key === "ArrowLeft")
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
if (event.key === "Tab") {
|
|
284
|
+
// Tab out of a menu means "I am done here", not "next item".
|
|
285
|
+
options.onCloseAll();
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (inside)
|
|
289
|
+
seek.handleKey(event);
|
|
290
|
+
}
|
|
291
|
+
function onPointerOver(event) {
|
|
292
|
+
const target = event.target;
|
|
293
|
+
if (!(target instanceof Element))
|
|
294
|
+
return;
|
|
295
|
+
const item = target.closest("[data-nebula-item]");
|
|
296
|
+
if (!(item instanceof HTMLElement))
|
|
297
|
+
return;
|
|
298
|
+
if (item.hasAttribute("data-disabled"))
|
|
299
|
+
return;
|
|
300
|
+
focusSilently(item);
|
|
301
|
+
// Hover focuses the row directly rather than going through rovingFocus,
|
|
302
|
+
// so its `onFocusChange` never runs — the close has to be explicit here
|
|
303
|
+
// or the pointer leaves a trail of open panels behind it.
|
|
304
|
+
if (item.getAttribute("data-submenu") !== null)
|
|
305
|
+
openSubmenu(item);
|
|
306
|
+
else
|
|
307
|
+
closeSubmenusFrom(0);
|
|
308
|
+
}
|
|
309
|
+
panel.addEventListener("keydown", onKeyDown);
|
|
310
|
+
panel.addEventListener("pointerover", onPointerOver);
|
|
311
|
+
if (options.autoFocusFirst === true) {
|
|
312
|
+
const first = group.items()[0];
|
|
313
|
+
if (first !== undefined)
|
|
314
|
+
focusSilently(first);
|
|
315
|
+
}
|
|
316
|
+
return () => {
|
|
317
|
+
closeSubmenusFrom(0);
|
|
318
|
+
panel.removeEventListener("keydown", onKeyDown);
|
|
319
|
+
panel.removeEventListener("pointerover", onPointerOver);
|
|
320
|
+
seek.reset();
|
|
321
|
+
group.destroy();
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
/** Move each submenu's entries from the build-time map onto its element. */
|
|
325
|
+
function claimSubmenuEntries(panel) {
|
|
326
|
+
for (const node of panel.querySelectorAll("[data-submenu]")) {
|
|
327
|
+
const id = node.getAttribute("data-submenu");
|
|
328
|
+
if (id === null)
|
|
329
|
+
continue;
|
|
330
|
+
const entries = pendingSubmenus.get(id);
|
|
331
|
+
if (entries === undefined)
|
|
332
|
+
continue;
|
|
333
|
+
submenuEntries.set(node, entries);
|
|
334
|
+
pendingSubmenus.delete(id);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
// ─── entry guards ────────────────────────────────────────────────────
|
|
338
|
+
//
|
|
339
|
+
// Discriminating on an optional `type` needs real guards: `entry.type ===
|
|
340
|
+
// "item"` is false for a plain action, which leaves it undiscriminated. Each
|
|
341
|
+
// guard names the shape it accepts so the renderer narrows without a cast.
|
|
342
|
+
function isSeparator(entry) {
|
|
343
|
+
return entry.type === "separator";
|
|
344
|
+
}
|
|
345
|
+
function isLabel(entry) {
|
|
346
|
+
return entry.type === "label";
|
|
347
|
+
}
|
|
348
|
+
function isCheckbox(entry) {
|
|
349
|
+
return entry.type === "checkbox";
|
|
350
|
+
}
|
|
351
|
+
function isRadioGroup(entry) {
|
|
352
|
+
return entry.type === "radio-group";
|
|
353
|
+
}
|
|
354
|
+
function isSubmenu(entry) {
|
|
355
|
+
return entry.type === "submenu";
|
|
356
|
+
}
|