@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,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DropdownMenu — a menu opened from a button.
|
|
3
|
+
*
|
|
4
|
+
* The panel, its entries and its keyboard model all come from `./menu.js`;
|
|
5
|
+
* this component contributes the trigger and the open/close state.
|
|
6
|
+
*
|
|
7
|
+
* Two behaviours are specific to a button-opened menu:
|
|
8
|
+
*
|
|
9
|
+
* - Opening with the keyboard focuses the first item; opening with the pointer
|
|
10
|
+
* does not. A keyboard user has no other way in, while pre-highlighting an
|
|
11
|
+
* item for a mouse user suggests it is about to be chosen.
|
|
12
|
+
* - ArrowDown on the trigger opens the menu *and* enters it, which is the
|
|
13
|
+
* behaviour of every native menu button and the one users try first.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { component, html } from "@c9up/aurora";
|
|
17
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
18
|
+
import { cn } from "../lib/cn.js";
|
|
19
|
+
import { uid } from "../lib/id.js";
|
|
20
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
21
|
+
import { controllable } from "../primitives/controllable.js";
|
|
22
|
+
import type { Placement } from "../primitives/floating.js";
|
|
23
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
24
|
+
import { type MenuEntry, menuPanel, wireMenu } from "../primitives/menu.js";
|
|
25
|
+
|
|
26
|
+
export interface DropdownMenuProps {
|
|
27
|
+
trigger?: Slot;
|
|
28
|
+
entries: readonly MenuEntry[];
|
|
29
|
+
open?: Reactive<boolean>;
|
|
30
|
+
defaultOpen?: boolean;
|
|
31
|
+
onOpenChange?: (open: boolean) => void;
|
|
32
|
+
placement?: Placement;
|
|
33
|
+
class?: Reactive<string>;
|
|
34
|
+
triggerClass?: Reactive<string>;
|
|
35
|
+
contentClass?: Reactive<string>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export const DropdownMenu = component<DropdownMenuProps>((props) => {
|
|
39
|
+
const triggerId = uid("dropdown-trigger");
|
|
40
|
+
const contentId = uid("dropdown-content");
|
|
41
|
+
|
|
42
|
+
const state = controllable<boolean>({
|
|
43
|
+
value: props.open,
|
|
44
|
+
initial: props.defaultOpen ?? false,
|
|
45
|
+
onChange: props.onOpenChange,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// Set by the trigger's key handler just before opening, and consumed by
|
|
49
|
+
// `onOpened`. The panel does not exist yet at the moment of the keypress, so
|
|
50
|
+
// the intent has to survive the gap.
|
|
51
|
+
let enterOnOpen = false;
|
|
52
|
+
let unwire: (() => void) | null = null;
|
|
53
|
+
|
|
54
|
+
function close(): void {
|
|
55
|
+
state.set(false);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
floatingSurface({
|
|
59
|
+
anchor: () => document.getElementById(triggerId),
|
|
60
|
+
open: () => state.current(),
|
|
61
|
+
onClose: close,
|
|
62
|
+
placement: props.placement ?? "bottom-start",
|
|
63
|
+
offset: 4,
|
|
64
|
+
content: () =>
|
|
65
|
+
menuPanel({
|
|
66
|
+
id: contentId,
|
|
67
|
+
entries: props.entries,
|
|
68
|
+
onCloseAll: close,
|
|
69
|
+
labelledBy: triggerId,
|
|
70
|
+
class: read(props.contentClass),
|
|
71
|
+
}),
|
|
72
|
+
onOpened: (panel) => {
|
|
73
|
+
const enterItems = enterOnOpen;
|
|
74
|
+
enterOnOpen = false;
|
|
75
|
+
unwire = wireMenu(panel, {
|
|
76
|
+
onCloseAll: close,
|
|
77
|
+
autoFocusFirst: enterItems,
|
|
78
|
+
});
|
|
79
|
+
// The panel takes focus only when no item did — it needs to hold focus
|
|
80
|
+
// so Escape and the arrows reach it rather than the page behind, but
|
|
81
|
+
// focusing it unconditionally steals focus straight back from the item
|
|
82
|
+
// `autoFocusFirst` just landed on, and opening with the keyboard would
|
|
83
|
+
// never reach the first entry.
|
|
84
|
+
if (!enterItems) panel.focus({ preventScroll: true });
|
|
85
|
+
},
|
|
86
|
+
onClosed: () => {
|
|
87
|
+
unwire?.();
|
|
88
|
+
unwire = null;
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function onTriggerKeyDown(event: KeyboardEvent): void {
|
|
93
|
+
if (event.key !== "ArrowDown" && event.key !== "Enter" && event.key !== " ")
|
|
94
|
+
return;
|
|
95
|
+
event.preventDefault();
|
|
96
|
+
enterOnOpen = true;
|
|
97
|
+
state.set(true);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return html`<button
|
|
101
|
+
type="button"
|
|
102
|
+
data-slot="dropdown-menu-trigger"
|
|
103
|
+
id="${triggerId}"
|
|
104
|
+
aria-haspopup="menu"
|
|
105
|
+
aria-expanded="${() => (state.current() ? "true" : "false")}"
|
|
106
|
+
aria-controls="${() => (state.current() ? contentId : undefined)}"
|
|
107
|
+
data-state="${() => (state.current() ? "open" : "closed")}"
|
|
108
|
+
class="${() => cn(read(props.triggerClass))}"
|
|
109
|
+
@click="${() => state.set(!state.current())}"
|
|
110
|
+
@keydown="${onTriggerKeyDown}"
|
|
111
|
+
>${slot(props.trigger)}</button>`;
|
|
112
|
+
});
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Form — binding between Aurora's form controller and nebula's field markup.
|
|
3
|
+
*
|
|
4
|
+
* shadcn's Form is a set of adapters over `react-hook-form`, and none of that
|
|
5
|
+
* layer is needed here: Aurora already ships `form()`, a reactive controller
|
|
6
|
+
* with values, per-field errors, touched state, submit-in-flight and optional
|
|
7
|
+
* rune/zod-shaped validation. Reimplementing it would be a second source of
|
|
8
|
+
* truth for the same thing.
|
|
9
|
+
*
|
|
10
|
+
* So this file is only the join. It wires one field to one `Field` plus one
|
|
11
|
+
* control, and takes care of the three things that otherwise get forgotten:
|
|
12
|
+
* `aria-invalid` when the field has an error, `aria-describedby` pointing at
|
|
13
|
+
* the error element, and marking the field touched on blur so an error appears
|
|
14
|
+
* after leaving a field rather than while typing in it.
|
|
15
|
+
*
|
|
16
|
+
* const login = form({
|
|
17
|
+
* initial: { email: "", password: "" },
|
|
18
|
+
* validate: (values) => (values.email === "" ? { email: "Required" } : {}),
|
|
19
|
+
* submit: (values) => http.post("/login", values),
|
|
20
|
+
* })
|
|
21
|
+
*
|
|
22
|
+
* Form({
|
|
23
|
+
* form: login,
|
|
24
|
+
* children: [
|
|
25
|
+
* TextField({ bind: bind(login, "email"), label: "Email", type: "email" }),
|
|
26
|
+
* TextField({ bind: bind(login, "password"), label: "Password", type: "password" }),
|
|
27
|
+
* SubmitButton({ form: login, label: "Sign in" }),
|
|
28
|
+
* ],
|
|
29
|
+
* })
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import type { Form as AuroraForm, FormField } from "@c9up/aurora";
|
|
33
|
+
import { component, html } from "@c9up/aurora";
|
|
34
|
+
import { Button } from "../atoms/Button.js";
|
|
35
|
+
import { Input } from "../atoms/Input.js";
|
|
36
|
+
import { Spinner } from "../atoms/Spinner.js";
|
|
37
|
+
import { Textarea } from "../atoms/Textarea.js";
|
|
38
|
+
import type { Child, Slot } from "../lib/children.js";
|
|
39
|
+
import { slot } from "../lib/children.js";
|
|
40
|
+
import { cn } from "../lib/cn.js";
|
|
41
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
42
|
+
import { Field, fieldIds } from "../molecules/Field.js";
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* One field, with a setter that already knows which key it writes to.
|
|
46
|
+
*
|
|
47
|
+
* This shape exists so the input components are not generic over the form.
|
|
48
|
+
* Inside a function generic in `T`, TypeScript cannot prove a `string` is
|
|
49
|
+
* assignable to `T[K]`, and the usual escape is a cast. `bind()` is called at
|
|
50
|
+
* a *concrete* call site, where `K` is a literal and `T[K]` resolves to a real
|
|
51
|
+
* type — so `bind(login, "email")` is a `FieldBinding<string>` and handing it
|
|
52
|
+
* to a numeric input is a compile error rather than a runtime surprise.
|
|
53
|
+
*/
|
|
54
|
+
export interface FieldBinding<V> {
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly field: FormField<V>;
|
|
57
|
+
set(value: V): void;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Bind one key of a form. Call it at the field's own call site. */
|
|
61
|
+
export function bind<T, K extends keyof T & string>(
|
|
62
|
+
form: AuroraForm<T>,
|
|
63
|
+
name: K,
|
|
64
|
+
): FieldBinding<T[K]> {
|
|
65
|
+
return {
|
|
66
|
+
name,
|
|
67
|
+
field: form.field(name),
|
|
68
|
+
set: (value: T[K]) => form.set(name, value),
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface FormProps<T> {
|
|
73
|
+
form: AuroraForm<T>;
|
|
74
|
+
children?: Slot;
|
|
75
|
+
class?: Reactive<string>;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The `<form>` element itself.
|
|
80
|
+
*
|
|
81
|
+
* `novalidate` on purpose. The browser's own validation bubbles cannot be
|
|
82
|
+
* styled, appear one at a time, and vanish on scroll — and they would fire
|
|
83
|
+
* before the controller's validation ever runs, so the user would see two
|
|
84
|
+
* different error presentations for the same field.
|
|
85
|
+
*/
|
|
86
|
+
export function Form<T>(props: FormProps<T>): ReturnType<typeof html> {
|
|
87
|
+
return html`<form
|
|
88
|
+
data-slot="form"
|
|
89
|
+
novalidate
|
|
90
|
+
class="${() => cn("flex flex-col gap-6", read(props.class))}"
|
|
91
|
+
@submit="${(event: Event) => {
|
|
92
|
+
void props.form.handleSubmit(event);
|
|
93
|
+
}}"
|
|
94
|
+
>${slot(props.children)}</form>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
interface FieldShellProps {
|
|
98
|
+
bind: FieldBinding<string>;
|
|
99
|
+
label: Child;
|
|
100
|
+
description?: Child;
|
|
101
|
+
placeholder?: string;
|
|
102
|
+
required?: boolean;
|
|
103
|
+
disabled?: Reactive<boolean>;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export interface TextFieldProps extends FieldShellProps {
|
|
107
|
+
type?: string;
|
|
108
|
+
autocomplete?: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* A labelled text input bound to one field.
|
|
113
|
+
*
|
|
114
|
+
* The error is shown only once the field has been touched, which is what stops
|
|
115
|
+
* a pristine form opening with "Required" under every empty box.
|
|
116
|
+
*
|
|
117
|
+
* When errors actually appear is Aurora's decision, not this file's, and it is
|
|
118
|
+
* worth knowing: `form()` populates them on `validate()` — which `handleSubmit`
|
|
119
|
+
* calls — and clears a field's error as soon as it is written to. So the
|
|
120
|
+
* sequence is: submit, see every problem at once, and watch each one disappear
|
|
121
|
+
* as it is fixed. Typing into an untouched field does not raise an error, and
|
|
122
|
+
* `markTouched` on blur does not validate; it only lifts the gate for an error
|
|
123
|
+
* that validation has already found.
|
|
124
|
+
*/
|
|
125
|
+
export const TextField = component<TextFieldProps>((props) => {
|
|
126
|
+
const ids = fieldIds();
|
|
127
|
+
const { field, set, name } = props.bind;
|
|
128
|
+
const visibleError = (): string | undefined =>
|
|
129
|
+
field.touched() ? (field.error() ?? undefined) : undefined;
|
|
130
|
+
|
|
131
|
+
return Field({
|
|
132
|
+
ids,
|
|
133
|
+
label: props.label,
|
|
134
|
+
description: props.description,
|
|
135
|
+
error: visibleError,
|
|
136
|
+
required: props.required,
|
|
137
|
+
disabled: props.disabled,
|
|
138
|
+
children: Input({
|
|
139
|
+
id: ids.control,
|
|
140
|
+
name,
|
|
141
|
+
type: props.type ?? "text",
|
|
142
|
+
placeholder: props.placeholder,
|
|
143
|
+
autocomplete: props.autocomplete,
|
|
144
|
+
required: props.required,
|
|
145
|
+
disabled: props.disabled,
|
|
146
|
+
invalid: () => visibleError() !== undefined,
|
|
147
|
+
describedBy: ids.describedBy,
|
|
148
|
+
value: () => field.value(),
|
|
149
|
+
onInput: set,
|
|
150
|
+
onBlur: () => field.markTouched(),
|
|
151
|
+
}),
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
export interface TextAreaFieldProps extends FieldShellProps {
|
|
156
|
+
rows?: number;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** The same, with a multi-line control. */
|
|
160
|
+
export const TextAreaField = component<TextAreaFieldProps>((props) => {
|
|
161
|
+
const ids = fieldIds();
|
|
162
|
+
const { field, set, name } = props.bind;
|
|
163
|
+
const visibleError = (): string | undefined =>
|
|
164
|
+
field.touched() ? (field.error() ?? undefined) : undefined;
|
|
165
|
+
|
|
166
|
+
return Field({
|
|
167
|
+
ids,
|
|
168
|
+
label: props.label,
|
|
169
|
+
description: props.description,
|
|
170
|
+
error: visibleError,
|
|
171
|
+
required: props.required,
|
|
172
|
+
disabled: props.disabled,
|
|
173
|
+
children: Textarea({
|
|
174
|
+
id: ids.control,
|
|
175
|
+
name,
|
|
176
|
+
rows: props.rows,
|
|
177
|
+
placeholder: props.placeholder,
|
|
178
|
+
required: props.required,
|
|
179
|
+
disabled: props.disabled,
|
|
180
|
+
invalid: () => visibleError() !== undefined,
|
|
181
|
+
describedBy: ids.describedBy,
|
|
182
|
+
value: () => field.value(),
|
|
183
|
+
onInput: set,
|
|
184
|
+
onBlur: () => field.markTouched(),
|
|
185
|
+
}),
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
export interface SubmitButtonProps<T> {
|
|
190
|
+
form: AuroraForm<T>;
|
|
191
|
+
label: Child;
|
|
192
|
+
/** Replaces the label while the submit is in flight. */
|
|
193
|
+
pendingLabel?: Child;
|
|
194
|
+
class?: Reactive<string>;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A submit button that disables itself while the submit is in flight.
|
|
199
|
+
*
|
|
200
|
+
* Disabled on `submitting` only — never on `!valid`. A submit button that is
|
|
201
|
+
* dead until the form is correct gives the user nothing to press and no
|
|
202
|
+
* explanation; pressing it and seeing every error at once is how a form tells
|
|
203
|
+
* you what is wrong.
|
|
204
|
+
*/
|
|
205
|
+
export function SubmitButton<T>(
|
|
206
|
+
props: SubmitButtonProps<T>,
|
|
207
|
+
): ReturnType<typeof html> {
|
|
208
|
+
return Button({
|
|
209
|
+
type: "submit",
|
|
210
|
+
disabled: () => props.form.submitting(),
|
|
211
|
+
class: props.class,
|
|
212
|
+
children: () =>
|
|
213
|
+
props.form.submitting()
|
|
214
|
+
? [Spinner({ class: "mr-2" }), props.pendingLabel ?? props.label]
|
|
215
|
+
: props.label,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* HoverCard — a rich preview shown on hover.
|
|
3
|
+
*
|
|
4
|
+
* A Tooltip carries a phrase; a HoverCard carries content — an avatar, a
|
|
5
|
+
* summary, a link. That difference drives every behavioural difference between
|
|
6
|
+
* the two.
|
|
7
|
+
*
|
|
8
|
+
* It opens more slowly, because the payload is heavier and a card flashing
|
|
9
|
+
* past is worse than a tooltip flashing past. It closes more slowly, because
|
|
10
|
+
* the user is expected to move the pointer *into* it. And it is never the only
|
|
11
|
+
* route to what it contains: pointer-only content is unreachable by keyboard
|
|
12
|
+
* and touch, so a hover card must preview something a click already reaches.
|
|
13
|
+
*
|
|
14
|
+
* `role="dialog"` rather than `tooltip`, since it holds interactive content —
|
|
15
|
+
* a tooltip's contents are announced as a description, which is wrong for
|
|
16
|
+
* something containing a link.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { component, html, onUnmount, signal } from "@c9up/aurora";
|
|
20
|
+
import { type Slot, slot } from "../lib/children.js";
|
|
21
|
+
import { cn } from "../lib/cn.js";
|
|
22
|
+
import { uid } from "../lib/id.js";
|
|
23
|
+
import { zoomInOut } from "../lib/motion.js";
|
|
24
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
25
|
+
import type { Placement } from "../primitives/floating.js";
|
|
26
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
27
|
+
|
|
28
|
+
const OPEN_DELAY_MS = 700;
|
|
29
|
+
const CLOSE_DELAY_MS = 300;
|
|
30
|
+
|
|
31
|
+
export interface HoverCardProps {
|
|
32
|
+
trigger?: Slot;
|
|
33
|
+
children?: Slot;
|
|
34
|
+
placement?: Placement;
|
|
35
|
+
openDelay?: number;
|
|
36
|
+
closeDelay?: number;
|
|
37
|
+
class?: Reactive<string>;
|
|
38
|
+
contentClass?: Reactive<string>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export const HoverCard = component<HoverCardProps>((props) => {
|
|
42
|
+
const triggerId = uid("hover-card-trigger");
|
|
43
|
+
const contentId = uid("hover-card-content");
|
|
44
|
+
const open = signal(false);
|
|
45
|
+
|
|
46
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
|
|
48
|
+
function clear(): void {
|
|
49
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
50
|
+
timer = undefined;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function schedule(next: boolean, delay: number): void {
|
|
54
|
+
clear();
|
|
55
|
+
timer = setTimeout(() => open(next), delay);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
onUnmount(clear);
|
|
59
|
+
|
|
60
|
+
floatingSurface({
|
|
61
|
+
anchor: () => document.getElementById(triggerId),
|
|
62
|
+
open: () => open(),
|
|
63
|
+
onClose: () => {
|
|
64
|
+
clear();
|
|
65
|
+
open(false);
|
|
66
|
+
},
|
|
67
|
+
placement: props.placement ?? "bottom",
|
|
68
|
+
offset: 8,
|
|
69
|
+
content: () =>
|
|
70
|
+
html`<div
|
|
71
|
+
data-slot="hover-card-content"
|
|
72
|
+
id="${contentId}"
|
|
73
|
+
role="dialog"
|
|
74
|
+
aria-labelledby="${triggerId}"
|
|
75
|
+
class="${cn(
|
|
76
|
+
"bg-popover text-popover-foreground z-50 w-64 rounded-md border p-4 shadow-md outline-none",
|
|
77
|
+
zoomInOut,
|
|
78
|
+
read(props.contentClass),
|
|
79
|
+
)}"
|
|
80
|
+
@pointerenter="${clear}"
|
|
81
|
+
@pointerleave="${() => schedule(false, props.closeDelay ?? CLOSE_DELAY_MS)}"
|
|
82
|
+
>${slot(props.children)}</div>`,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return html`<span
|
|
86
|
+
data-slot="hover-card-trigger"
|
|
87
|
+
id="${triggerId}"
|
|
88
|
+
class="${() => cn("inline-flex", read(props.class))}"
|
|
89
|
+
@pointerenter="${() => schedule(true, props.openDelay ?? OPEN_DELAY_MS)}"
|
|
90
|
+
@pointerleave="${() => schedule(false, props.closeDelay ?? CLOSE_DELAY_MS)}"
|
|
91
|
+
>${slot(props.trigger)}</span>`;
|
|
92
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Menubar — an application menu bar.
|
|
3
|
+
*
|
|
4
|
+
* A row of menu buttons where at most one menu is open. The behaviour that
|
|
5
|
+
* makes it a menubar rather than a row of independent dropdowns is the
|
|
6
|
+
* handover: once a menu is open, moving the pointer onto a neighbouring button
|
|
7
|
+
* switches to it without a click, and ArrowLeft/ArrowRight do the same from
|
|
8
|
+
* the keyboard. That is how every desktop menu bar behaves, and a row of
|
|
9
|
+
* dropdowns that each need their own click is the thing users notice.
|
|
10
|
+
*
|
|
11
|
+
* One `floatingSurface` for the bar, not one per menu. The open menu is an
|
|
12
|
+
* index, so switching moves the same surface to a new anchor instead of
|
|
13
|
+
* closing one and opening another — which would flash, and would lose the
|
|
14
|
+
* exit animation into the entry animation.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { component, html, signal } from "@c9up/aurora";
|
|
18
|
+
import type { Child } from "../lib/children.js";
|
|
19
|
+
import { cn } from "../lib/cn.js";
|
|
20
|
+
import { uid } from "../lib/id.js";
|
|
21
|
+
import { type Reactive, read } from "../lib/props.js";
|
|
22
|
+
import { floatingSurface } from "../primitives/floatingSurface.js";
|
|
23
|
+
import { type MenuEntry, menuPanel, wireMenu } from "../primitives/menu.js";
|
|
24
|
+
|
|
25
|
+
export interface MenubarMenu {
|
|
26
|
+
label: Child;
|
|
27
|
+
entries: readonly MenuEntry[];
|
|
28
|
+
disabled?: boolean;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface MenubarProps {
|
|
32
|
+
menus: readonly MenubarMenu[];
|
|
33
|
+
class?: Reactive<string>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export const Menubar = component<MenubarProps>((props) => {
|
|
37
|
+
const contentId = uid("menubar-content");
|
|
38
|
+
const triggerIds = props.menus.map(() => uid("menubar-trigger"));
|
|
39
|
+
/** Index of the open menu, or `-1`. */
|
|
40
|
+
const openIndex = signal(-1);
|
|
41
|
+
|
|
42
|
+
let unwire: (() => void) | null = null;
|
|
43
|
+
let focusFirstOnOpen = false;
|
|
44
|
+
|
|
45
|
+
function close(): void {
|
|
46
|
+
openIndex(-1);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function openAt(index: number, withKeyboard: boolean): void {
|
|
50
|
+
if (props.menus[index]?.disabled === true) return;
|
|
51
|
+
focusFirstOnOpen = withKeyboard;
|
|
52
|
+
openIndex(index);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function move(delta: number): void {
|
|
56
|
+
const count = props.menus.length;
|
|
57
|
+
if (count === 0) return;
|
|
58
|
+
const from = openIndex();
|
|
59
|
+
if (from === -1) return;
|
|
60
|
+
openAt((from + delta + count) % count, true);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
floatingSurface({
|
|
64
|
+
anchor: () => {
|
|
65
|
+
const id = triggerIds[openIndex()];
|
|
66
|
+
return id === undefined ? null : document.getElementById(id);
|
|
67
|
+
},
|
|
68
|
+
open: () => openIndex() !== -1,
|
|
69
|
+
onClose: close,
|
|
70
|
+
placement: "bottom-start",
|
|
71
|
+
offset: 4,
|
|
72
|
+
content: () =>
|
|
73
|
+
menuPanel({
|
|
74
|
+
id: contentId,
|
|
75
|
+
entries: props.menus[openIndex()]?.entries ?? [],
|
|
76
|
+
onCloseAll: close,
|
|
77
|
+
labelledBy: triggerIds[openIndex()],
|
|
78
|
+
}),
|
|
79
|
+
onOpened: (panel) => {
|
|
80
|
+
const enterItems = focusFirstOnOpen;
|
|
81
|
+
focusFirstOnOpen = false;
|
|
82
|
+
unwire = wireMenu(panel, {
|
|
83
|
+
onCloseAll: close,
|
|
84
|
+
autoFocusFirst: enterItems,
|
|
85
|
+
});
|
|
86
|
+
// Only when no item took focus. Focusing the panel unconditionally
|
|
87
|
+
// steals focus back from the item `autoFocusFirst` just landed on, and
|
|
88
|
+
// opening a menu with the keyboard would never reach its first entry.
|
|
89
|
+
if (!enterItems) panel.focus({ preventScroll: true });
|
|
90
|
+
// Left/right walk the bar from inside the open panel. `menu.js` claims
|
|
91
|
+
// ArrowLeft for closing a submenu and stops propagation when it does,
|
|
92
|
+
// so this only fires at the top level.
|
|
93
|
+
panel.addEventListener("keydown", onPanelKeyDown);
|
|
94
|
+
},
|
|
95
|
+
onClosed: () => {
|
|
96
|
+
unwire?.();
|
|
97
|
+
unwire = null;
|
|
98
|
+
},
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function onPanelKeyDown(event: KeyboardEvent): void {
|
|
102
|
+
if (event.key === "ArrowRight") {
|
|
103
|
+
event.preventDefault();
|
|
104
|
+
move(1);
|
|
105
|
+
} else if (event.key === "ArrowLeft") {
|
|
106
|
+
event.preventDefault();
|
|
107
|
+
move(-1);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return html`<div
|
|
112
|
+
data-slot="menubar"
|
|
113
|
+
role="menubar"
|
|
114
|
+
class="${() =>
|
|
115
|
+
cn(
|
|
116
|
+
"bg-background flex h-9 items-center gap-1 rounded-md border p-1 shadow-xs",
|
|
117
|
+
read(props.class),
|
|
118
|
+
)}"
|
|
119
|
+
>
|
|
120
|
+
${props.menus.map((menu, index) => renderTrigger(menu, index))}
|
|
121
|
+
</div>`;
|
|
122
|
+
|
|
123
|
+
function renderTrigger(menu: MenubarMenu, index: number): Child {
|
|
124
|
+
return html`<button
|
|
125
|
+
type="button"
|
|
126
|
+
role="menuitem"
|
|
127
|
+
data-slot="menubar-trigger"
|
|
128
|
+
id="${triggerIds[index]}"
|
|
129
|
+
aria-haspopup="menu"
|
|
130
|
+
aria-expanded="${() => (openIndex() === index ? "true" : "false")}"
|
|
131
|
+
aria-controls="${() => (openIndex() === index ? contentId : undefined)}"
|
|
132
|
+
data-state="${() => (openIndex() === index ? "open" : "closed")}"
|
|
133
|
+
?disabled="${menu.disabled === true}"
|
|
134
|
+
class="focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex select-none items-center rounded-sm px-2 py-1 text-sm font-medium outline-none disabled:pointer-events-none disabled:opacity-50"
|
|
135
|
+
@click="${() => (openIndex() === index ? close() : openAt(index, false))}"
|
|
136
|
+
@pointerenter="${() => {
|
|
137
|
+
// Hover switches menus only while one is already open — otherwise
|
|
138
|
+
// the bar would spring menus at a pointer merely passing over it.
|
|
139
|
+
if (openIndex() !== -1) openAt(index, false);
|
|
140
|
+
}}"
|
|
141
|
+
@keydown="${(event: KeyboardEvent) => {
|
|
142
|
+
if (
|
|
143
|
+
event.key === "ArrowDown" ||
|
|
144
|
+
event.key === "Enter" ||
|
|
145
|
+
event.key === " "
|
|
146
|
+
) {
|
|
147
|
+
event.preventDefault();
|
|
148
|
+
openAt(index, true);
|
|
149
|
+
} else if (event.key === "ArrowRight") {
|
|
150
|
+
event.preventDefault();
|
|
151
|
+
focusTrigger(index + 1);
|
|
152
|
+
} else if (event.key === "ArrowLeft") {
|
|
153
|
+
event.preventDefault();
|
|
154
|
+
focusTrigger(index - 1);
|
|
155
|
+
}
|
|
156
|
+
}}"
|
|
157
|
+
>${menu.label}</button>`;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function focusTrigger(index: number): void {
|
|
161
|
+
const count = props.menus.length;
|
|
162
|
+
if (count === 0) return;
|
|
163
|
+
const id = triggerIds[(index + count) % count];
|
|
164
|
+
if (id === undefined) return;
|
|
165
|
+
document.getElementById(id)?.focus({ preventScroll: true });
|
|
166
|
+
}
|
|
167
|
+
});
|