@chromvoid/headless-ui 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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/a11y-contracts/index.d.ts +23 -0
  4. package/dist/a11y-contracts/index.js +1 -0
  5. package/dist/accordion/index.d.ts +78 -0
  6. package/dist/accordion/index.js +264 -0
  7. package/dist/adapters/index.d.ts +9 -0
  8. package/dist/adapters/index.js +1 -0
  9. package/dist/alert/index.d.ts +33 -0
  10. package/dist/alert/index.js +54 -0
  11. package/dist/alert-dialog/index.d.ts +69 -0
  12. package/dist/alert-dialog/index.js +94 -0
  13. package/dist/badge/index.d.ts +48 -0
  14. package/dist/badge/index.js +89 -0
  15. package/dist/breadcrumb/index.d.ts +55 -0
  16. package/dist/breadcrumb/index.js +77 -0
  17. package/dist/button/index.d.ts +46 -0
  18. package/dist/button/index.js +86 -0
  19. package/dist/callout/index.d.ts +41 -0
  20. package/dist/callout/index.js +63 -0
  21. package/dist/card/index.d.ts +54 -0
  22. package/dist/card/index.js +103 -0
  23. package/dist/carousel/index.d.ts +98 -0
  24. package/dist/carousel/index.js +243 -0
  25. package/dist/checkbox/index.d.ts +50 -0
  26. package/dist/checkbox/index.js +87 -0
  27. package/dist/combobox/index.d.ts +114 -0
  28. package/dist/combobox/index.js +431 -0
  29. package/dist/command-palette/index.d.ts +73 -0
  30. package/dist/command-palette/index.js +147 -0
  31. package/dist/context-menu/index.d.ts +111 -0
  32. package/dist/context-menu/index.js +372 -0
  33. package/dist/copy-button/index.d.ts +62 -0
  34. package/dist/copy-button/index.js +183 -0
  35. package/dist/core/index.d.ts +20 -0
  36. package/dist/core/index.js +2 -0
  37. package/dist/core/selection.d.ts +5 -0
  38. package/dist/core/selection.js +39 -0
  39. package/dist/core/value-range.d.ts +49 -0
  40. package/dist/core/value-range.js +134 -0
  41. package/dist/date-picker/index.d.ts +210 -0
  42. package/dist/date-picker/index.js +895 -0
  43. package/dist/dialog/index.d.ts +95 -0
  44. package/dist/dialog/index.js +153 -0
  45. package/dist/disclosure/index.d.ts +52 -0
  46. package/dist/disclosure/index.js +159 -0
  47. package/dist/drawer/index.d.ts +30 -0
  48. package/dist/drawer/index.js +39 -0
  49. package/dist/feed/index.d.ts +77 -0
  50. package/dist/feed/index.js +260 -0
  51. package/dist/grid/index.d.ts +103 -0
  52. package/dist/grid/index.js +415 -0
  53. package/dist/index.d.ts +51 -0
  54. package/dist/index.js +51 -0
  55. package/dist/input/index.d.ts +86 -0
  56. package/dist/input/index.js +156 -0
  57. package/dist/interactions/composite-navigation.d.ts +69 -0
  58. package/dist/interactions/composite-navigation.js +169 -0
  59. package/dist/interactions/index.d.ts +15 -0
  60. package/dist/interactions/index.js +4 -0
  61. package/dist/interactions/keyboard-intents.d.ts +16 -0
  62. package/dist/interactions/keyboard-intents.js +33 -0
  63. package/dist/interactions/overlay-focus.d.ts +40 -0
  64. package/dist/interactions/overlay-focus.js +93 -0
  65. package/dist/interactions/typeahead.d.ts +20 -0
  66. package/dist/interactions/typeahead.js +41 -0
  67. package/dist/landmarks/index.d.ts +39 -0
  68. package/dist/landmarks/index.js +58 -0
  69. package/dist/link/index.d.ts +34 -0
  70. package/dist/link/index.js +39 -0
  71. package/dist/listbox/index.d.ts +92 -0
  72. package/dist/listbox/index.js +337 -0
  73. package/dist/menu/index.d.ts +132 -0
  74. package/dist/menu/index.js +541 -0
  75. package/dist/menu-button/index.d.ts +71 -0
  76. package/dist/menu-button/index.js +121 -0
  77. package/dist/meter/index.d.ts +45 -0
  78. package/dist/meter/index.js +106 -0
  79. package/dist/number/index.d.ts +113 -0
  80. package/dist/number/index.js +252 -0
  81. package/dist/popover/index.d.ts +70 -0
  82. package/dist/popover/index.js +126 -0
  83. package/dist/progress/index.d.ts +49 -0
  84. package/dist/progress/index.js +79 -0
  85. package/dist/radio-group/index.d.ts +61 -0
  86. package/dist/radio-group/index.js +150 -0
  87. package/dist/select/index.d.ts +92 -0
  88. package/dist/select/index.js +239 -0
  89. package/dist/sidebar/index.d.ts +74 -0
  90. package/dist/sidebar/index.js +186 -0
  91. package/dist/slider/index.d.ts +61 -0
  92. package/dist/slider/index.js +150 -0
  93. package/dist/slider-multi-thumb/index.d.ts +70 -0
  94. package/dist/slider-multi-thumb/index.js +222 -0
  95. package/dist/spinbutton/index.d.ts +75 -0
  96. package/dist/spinbutton/index.js +214 -0
  97. package/dist/spinner/index.d.ts +1 -0
  98. package/dist/spinner/index.js +1 -0
  99. package/dist/spinner/spinner.d.ts +23 -0
  100. package/dist/spinner/spinner.js +25 -0
  101. package/dist/switch/index.d.ts +40 -0
  102. package/dist/switch/index.js +61 -0
  103. package/dist/table/index.d.ts +117 -0
  104. package/dist/table/index.js +377 -0
  105. package/dist/tabs/index.d.ts +63 -0
  106. package/dist/tabs/index.js +174 -0
  107. package/dist/textarea/index.d.ts +68 -0
  108. package/dist/textarea/index.js +137 -0
  109. package/dist/toast/index.d.ts +67 -0
  110. package/dist/toast/index.js +145 -0
  111. package/dist/toolbar/index.d.ts +59 -0
  112. package/dist/toolbar/index.js +139 -0
  113. package/dist/tooltip/index.d.ts +52 -0
  114. package/dist/tooltip/index.js +169 -0
  115. package/dist/treegrid/index.d.ts +101 -0
  116. package/dist/treegrid/index.js +463 -0
  117. package/dist/treeview/index.d.ts +68 -0
  118. package/dist/treeview/index.js +370 -0
  119. package/dist/window-splitter/index.d.ts +65 -0
  120. package/dist/window-splitter/index.js +204 -0
  121. package/package.json +92 -0
  122. package/specs/ADR-001-headless-architecture.md +461 -0
  123. package/specs/ADR-002-repo-release-model.md +108 -0
  124. package/specs/ADR-003-public-api-versioning.md +136 -0
  125. package/specs/ADR-004-focus-selection-policy.md +117 -0
  126. package/specs/IMPLEMENTATION-ROADMAP.md +237 -0
  127. package/specs/ISSUE-BACKLOG.md +681 -0
  128. package/specs/RELEASE-CANDIDATE.md +30 -0
  129. package/specs/components/accordion.md +130 -0
  130. package/specs/components/alert-dialog.md +72 -0
  131. package/specs/components/alert.md +65 -0
  132. package/specs/components/badge.md +220 -0
  133. package/specs/components/breadcrumb.md +74 -0
  134. package/specs/components/button.md +115 -0
  135. package/specs/components/callout.md +195 -0
  136. package/specs/components/card.md +280 -0
  137. package/specs/components/carousel.md +140 -0
  138. package/specs/components/checkbox.md +172 -0
  139. package/specs/components/combobox.md +423 -0
  140. package/specs/components/command-palette.md +92 -0
  141. package/specs/components/context-menu.md +556 -0
  142. package/specs/components/copy-button.md +293 -0
  143. package/specs/components/date-picker.md +400 -0
  144. package/specs/components/dialog.md +298 -0
  145. package/specs/components/disclosure.md +257 -0
  146. package/specs/components/drawer.md +353 -0
  147. package/specs/components/feed.md +265 -0
  148. package/specs/components/grid.md +186 -0
  149. package/specs/components/input.md +254 -0
  150. package/specs/components/landmarks.md +136 -0
  151. package/specs/components/link.md +134 -0
  152. package/specs/components/listbox.md +351 -0
  153. package/specs/components/menu-button.md +76 -0
  154. package/specs/components/menu.md +623 -0
  155. package/specs/components/meter.md +149 -0
  156. package/specs/components/number.md +393 -0
  157. package/specs/components/popover.md +252 -0
  158. package/specs/components/progress.md +188 -0
  159. package/specs/components/radio-group.md +151 -0
  160. package/specs/components/select.md +144 -0
  161. package/specs/components/sidebar.md +321 -0
  162. package/specs/components/slider-multi-thumb.md +78 -0
  163. package/specs/components/slider.md +84 -0
  164. package/specs/components/spinbutton.md +140 -0
  165. package/specs/components/spinner.md +132 -0
  166. package/specs/components/switch.md +175 -0
  167. package/specs/components/table.md +403 -0
  168. package/specs/components/tabs.md +265 -0
  169. package/specs/components/textarea.md +185 -0
  170. package/specs/components/toast.md +198 -0
  171. package/specs/components/toolbar.md +278 -0
  172. package/specs/components/tooltip.md +252 -0
  173. package/specs/components/treegrid.md +281 -0
  174. package/specs/components/treeview.md +91 -0
  175. package/specs/components/window-splitter.md +297 -0
  176. package/specs/ops/git-shard-sync.md +107 -0
  177. package/specs/ops/release-checklist.md +76 -0
  178. package/specs/release/GAP-TO-GREEN-ISSUES.md +88 -0
  179. package/specs/release/api-freeze-candidate.md +54 -0
  180. package/specs/release/changelog-automation.md +76 -0
  181. package/specs/release/changelog.generated.md +53 -0
  182. package/specs/release/changelog.patch.generated.md +46 -0
  183. package/specs/release/consumer-integration.md +53 -0
  184. package/specs/release/migration-notes-pre-v1.md +40 -0
  185. package/specs/release/mvp-changelog.md +57 -0
  186. package/specs/release/release-notes-template.md +61 -0
  187. package/specs/release/release-rehearsal.md +113 -0
  188. package/specs/release/semver-deprecation-dry-run.md +89 -0
  189. package/specs/release/shard-release-drill-report.md +50 -0
  190. package/specs/release/shard-release-follow-ups.md +31 -0
  191. package/specs/signals.md +208 -0
@@ -0,0 +1,132 @@
1
+ import { type Atom, type Computed } from '@reatom/core';
2
+ export type MenuOpenSource = 'keyboard' | 'pointer' | 'programmatic';
3
+ export interface MenuItem {
4
+ id: string;
5
+ label?: string;
6
+ disabled?: boolean;
7
+ type?: 'normal' | 'checkbox' | 'radio';
8
+ group?: string;
9
+ checked?: boolean;
10
+ hasSubmenu?: boolean;
11
+ }
12
+ export interface MenuGroup {
13
+ id: string;
14
+ type: 'checkbox' | 'radio';
15
+ label?: string;
16
+ }
17
+ export interface CreateMenuOptions {
18
+ items: readonly MenuItem[];
19
+ idBase?: string;
20
+ ariaLabel?: string;
21
+ initialOpen?: boolean;
22
+ initialActiveId?: string | null;
23
+ closeOnSelect?: boolean;
24
+ typeahead?: boolean;
25
+ typeaheadTimeout?: number;
26
+ groups?: readonly MenuGroup[];
27
+ splitButton?: boolean;
28
+ }
29
+ export interface MenuState {
30
+ isOpen: Atom<boolean>;
31
+ activeId: Atom<string | null>;
32
+ selectedId: Atom<string | null>;
33
+ openedBy: Atom<MenuOpenSource | null>;
34
+ hasSelection: Computed<boolean>;
35
+ checkedIds: Atom<ReadonlySet<string>>;
36
+ openSubmenuId: Atom<string | null>;
37
+ submenuActiveId: Atom<string | null>;
38
+ }
39
+ export interface MenuTriggerProps {
40
+ id: string;
41
+ tabindex: '0';
42
+ 'aria-haspopup': 'menu';
43
+ 'aria-expanded': 'true' | 'false';
44
+ 'aria-controls': string;
45
+ 'aria-label'?: string;
46
+ }
47
+ export interface MenuProps {
48
+ id: string;
49
+ role: 'menu';
50
+ tabindex: '-1';
51
+ 'aria-label'?: string;
52
+ 'aria-activedescendant'?: string;
53
+ }
54
+ export interface MenuItemProps {
55
+ id: string;
56
+ role: 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';
57
+ tabindex: '-1';
58
+ 'aria-disabled'?: 'true';
59
+ 'data-active': 'true' | 'false';
60
+ 'aria-checked'?: 'true' | 'false';
61
+ 'aria-haspopup'?: 'menu';
62
+ 'aria-expanded'?: 'true' | 'false';
63
+ }
64
+ export interface MenuSubmenuProps {
65
+ id: string;
66
+ role: 'menu';
67
+ tabindex: '-1';
68
+ hidden: boolean;
69
+ 'aria-label'?: string;
70
+ }
71
+ export interface MenuSplitTriggerProps {
72
+ id: string;
73
+ tabindex: '0';
74
+ role: 'button';
75
+ }
76
+ export interface MenuSplitDropdownProps {
77
+ id: string;
78
+ tabindex: '0';
79
+ role: 'button';
80
+ 'aria-haspopup': 'menu';
81
+ 'aria-expanded': 'true' | 'false';
82
+ 'aria-controls': string;
83
+ 'aria-label': string;
84
+ }
85
+ export interface MenuGroupProps {
86
+ id: string;
87
+ role: 'group';
88
+ 'aria-label'?: string;
89
+ }
90
+ export interface MenuKeyboardEventLike {
91
+ key: string;
92
+ shiftKey?: boolean;
93
+ ctrlKey?: boolean;
94
+ metaKey?: boolean;
95
+ altKey?: boolean;
96
+ }
97
+ export interface MenuActions {
98
+ open(source?: MenuOpenSource): void;
99
+ close(): void;
100
+ toggle(source?: MenuOpenSource): void;
101
+ setActive(id: string | null): void;
102
+ moveNext(): void;
103
+ movePrev(): void;
104
+ moveFirst(): void;
105
+ moveLast(): void;
106
+ select(id: string): void;
107
+ toggleCheck(id: string): void;
108
+ openSubmenu(id: string): void;
109
+ closeSubmenu(): void;
110
+ handleTypeahead(char: string): void;
111
+ handleTriggerKeyDown(event: Pick<KeyboardEvent, 'key'>): void;
112
+ handleMenuKeyDown(event: MenuKeyboardEventLike): void;
113
+ handleItemPointerEnter(id: string): void;
114
+ handleItemPointerLeave(id: string): void;
115
+ setSubmenuItems(parentId: string, items: readonly MenuItem[]): void;
116
+ }
117
+ export interface MenuContracts {
118
+ getTriggerProps(): MenuTriggerProps;
119
+ getMenuProps(): MenuProps;
120
+ getItemProps(id: string): MenuItemProps;
121
+ getSubmenuProps(parentItemId: string): MenuSubmenuProps;
122
+ getSubmenuItemProps(parentItemId: string, childId: string): MenuItemProps;
123
+ getSplitTriggerProps(): MenuSplitTriggerProps;
124
+ getSplitDropdownProps(): MenuSplitDropdownProps;
125
+ getGroupProps(groupId: string): MenuGroupProps;
126
+ }
127
+ export interface MenuModel {
128
+ readonly state: MenuState;
129
+ readonly actions: MenuActions;
130
+ readonly contracts: MenuContracts;
131
+ }
132
+ export declare function createMenu(options: CreateMenuOptions): MenuModel;
@@ -0,0 +1,541 @@
1
+ import { action, atom, computed } from '@reatom/core';
2
+ import { mapListboxKeyboardIntent } from '../interactions/keyboard-intents.js';
3
+ import { advanceTypeaheadState, createInitialTypeaheadState, findTypeaheadMatch, isTypeaheadEvent, normalizeTypeaheadText, } from '../interactions/typeahead.js';
4
+ export function createMenu(options) {
5
+ const idBase = options.idBase ?? 'menu';
6
+ const closeOnSelect = options.closeOnSelect ?? true;
7
+ const typeaheadEnabled = options.typeahead ?? true;
8
+ const typeaheadTimeout = options.typeaheadTimeout ?? 500;
9
+ const splitButton = options.splitButton ?? false;
10
+ const itemById = new Map(options.items.map((item) => [item.id, item]));
11
+ const enabledIds = options.items.filter((item) => !item.disabled).map((item) => item.id);
12
+ const groupById = new Map((options.groups ?? []).map((g) => [g.id, g]));
13
+ const submenuItemsMap = new Map();
14
+ const submenuItemById = new Map();
15
+ const submenuEnabledIds = new Map();
16
+ const rebuildSubmenuMaps = (parentId, items) => {
17
+ submenuItemsMap.set(parentId, items);
18
+ for (const item of items) {
19
+ submenuItemById.set(item.id, item);
20
+ }
21
+ submenuEnabledIds.set(parentId, items.filter((item) => !item.disabled).map((item) => item.id));
22
+ };
23
+ const initialCheckedIds = new Set();
24
+ for (const item of options.items) {
25
+ if (item.checked && (item.type === 'checkbox' || item.type === 'radio')) {
26
+ initialCheckedIds.add(item.id);
27
+ }
28
+ }
29
+ const isOpenAtom = atom(options.initialOpen ?? false, `${idBase}.isOpen`);
30
+ const activeIdAtom = atom(null, `${idBase}.activeId`);
31
+ const selectedIdAtom = atom(null, `${idBase}.selectedId`);
32
+ const openedByAtom = atom(null, `${idBase}.openedBy`);
33
+ const hasSelectionAtom = computed(() => selectedIdAtom() != null, `${idBase}.hasSelection`);
34
+ const checkedIdsAtom = atom(initialCheckedIds, `${idBase}.checkedIds`);
35
+ const openSubmenuIdAtom = atom(null, `${idBase}.openSubmenuId`);
36
+ const submenuActiveIdAtom = atom(null, `${idBase}.submenuActiveId`);
37
+ let typeaheadState = createInitialTypeaheadState();
38
+ let hoverIntentTimer = null;
39
+ let hoverIntentTargetId = null;
40
+ const menuId = `${idBase}-menu`;
41
+ const itemDomId = (id) => `${idBase}-item-${id}`;
42
+ const setInitialActive = () => {
43
+ if (options.initialActiveId != null && enabledIds.includes(options.initialActiveId)) {
44
+ activeIdAtom.set(options.initialActiveId);
45
+ return;
46
+ }
47
+ activeIdAtom.set(enabledIds[0] ?? null);
48
+ };
49
+ const move = (direction) => {
50
+ if (enabledIds.length === 0) {
51
+ activeIdAtom.set(null);
52
+ return;
53
+ }
54
+ const activeId = activeIdAtom();
55
+ if (activeId == null || !enabledIds.includes(activeId)) {
56
+ activeIdAtom.set(enabledIds[0] ?? null);
57
+ return;
58
+ }
59
+ const currentIndex = enabledIds.indexOf(activeId);
60
+ const nextIndex = (currentIndex + direction + enabledIds.length) % enabledIds.length;
61
+ activeIdAtom.set(enabledIds[nextIndex] ?? null);
62
+ };
63
+ const moveSubmenu = (direction) => {
64
+ const parentId = openSubmenuIdAtom();
65
+ if (parentId == null)
66
+ return;
67
+ const enabled = submenuEnabledIds.get(parentId);
68
+ if (!enabled || enabled.length === 0)
69
+ return;
70
+ const currentId = submenuActiveIdAtom();
71
+ if (currentId == null || !enabled.includes(currentId)) {
72
+ submenuActiveIdAtom.set(enabled[0] ?? null);
73
+ return;
74
+ }
75
+ const currentIndex = enabled.indexOf(currentId);
76
+ const nextIndex = (currentIndex + direction + enabled.length) % enabled.length;
77
+ submenuActiveIdAtom.set(enabled[nextIndex] ?? null);
78
+ };
79
+ const open = action((source = 'programmatic') => {
80
+ isOpenAtom.set(true);
81
+ openedByAtom.set(source);
82
+ openSubmenuIdAtom.set(null);
83
+ submenuActiveIdAtom.set(null);
84
+ const activeId = activeIdAtom();
85
+ if (activeId == null || !enabledIds.includes(activeId)) {
86
+ setInitialActive();
87
+ }
88
+ }, `${idBase}.open`);
89
+ const close = action(() => {
90
+ isOpenAtom.set(false);
91
+ openedByAtom.set(null);
92
+ activeIdAtom.set(null);
93
+ openSubmenuIdAtom.set(null);
94
+ submenuActiveIdAtom.set(null);
95
+ }, `${idBase}.close`);
96
+ const toggle = action((source = 'programmatic') => {
97
+ if (isOpenAtom()) {
98
+ close();
99
+ }
100
+ else {
101
+ open(source);
102
+ }
103
+ }, `${idBase}.toggle`);
104
+ const setActiveAction = action((id) => {
105
+ if (id == null) {
106
+ activeIdAtom.set(null);
107
+ return;
108
+ }
109
+ if (!enabledIds.includes(id))
110
+ return;
111
+ activeIdAtom.set(id);
112
+ }, `${idBase}.setActive`);
113
+ const moveNext = action(() => {
114
+ move(1);
115
+ }, `${idBase}.moveNext`);
116
+ const movePrev = action(() => {
117
+ move(-1);
118
+ }, `${idBase}.movePrev`);
119
+ const moveFirst = action(() => {
120
+ activeIdAtom.set(enabledIds[0] ?? null);
121
+ }, `${idBase}.moveFirst`);
122
+ const moveLast = action(() => {
123
+ activeIdAtom.set(enabledIds[enabledIds.length - 1] ?? null);
124
+ }, `${idBase}.moveLast`);
125
+ const isCheckableItem = (item) => item.type === 'checkbox' || item.type === 'radio';
126
+ const performCheckToggle = (item) => {
127
+ const current = new Set(checkedIdsAtom());
128
+ if (item.type === 'checkbox') {
129
+ if (current.has(item.id)) {
130
+ current.delete(item.id);
131
+ }
132
+ else {
133
+ current.add(item.id);
134
+ }
135
+ }
136
+ else if (item.type === 'radio' && item.group) {
137
+ for (const otherItem of options.items) {
138
+ if (otherItem.group === item.group && otherItem.type === 'radio') {
139
+ current.delete(otherItem.id);
140
+ }
141
+ }
142
+ for (const [, subItems] of submenuItemsMap) {
143
+ for (const subItem of subItems) {
144
+ if (subItem.group === item.group && subItem.type === 'radio') {
145
+ current.delete(subItem.id);
146
+ }
147
+ }
148
+ }
149
+ current.add(item.id);
150
+ }
151
+ checkedIdsAtom.set(current);
152
+ };
153
+ const select = action((id) => {
154
+ const item = itemById.get(id) ?? submenuItemById.get(id);
155
+ if (!item || item.disabled)
156
+ return;
157
+ if (isCheckableItem(item)) {
158
+ performCheckToggle(item);
159
+ activeIdAtom.set(id);
160
+ return;
161
+ }
162
+ selectedIdAtom.set(id);
163
+ activeIdAtom.set(id);
164
+ if (closeOnSelect) {
165
+ close();
166
+ }
167
+ }, `${idBase}.select`);
168
+ const toggleCheck = action((id) => {
169
+ const item = itemById.get(id) ?? submenuItemById.get(id);
170
+ if (!item || item.disabled)
171
+ return;
172
+ if (!isCheckableItem(item))
173
+ return;
174
+ performCheckToggle(item);
175
+ }, `${idBase}.toggleCheck`);
176
+ const openSubmenu = action((id) => {
177
+ const item = itemById.get(id);
178
+ if (!item || !item.hasSubmenu)
179
+ return;
180
+ openSubmenuIdAtom.set(id);
181
+ const enabled = submenuEnabledIds.get(id);
182
+ submenuActiveIdAtom.set(enabled?.[0] ?? null);
183
+ }, `${idBase}.openSubmenu`);
184
+ const closeSubmenu = action(() => {
185
+ openSubmenuIdAtom.set(null);
186
+ submenuActiveIdAtom.set(null);
187
+ }, `${idBase}.closeSubmenu`);
188
+ const buildTypeaheadItems = (items) => items
189
+ .filter((item) => !item.disabled && item.label)
190
+ .map((item) => ({ id: item.id, text: normalizeTypeaheadText(item.label) }));
191
+ const handleTypeahead = action((char) => {
192
+ if (!typeaheadEnabled || !isOpenAtom())
193
+ return;
194
+ const now = Date.now();
195
+ const { query, next } = advanceTypeaheadState(typeaheadState, char, now, typeaheadTimeout);
196
+ typeaheadState = next;
197
+ const parentId = openSubmenuIdAtom();
198
+ if (parentId != null) {
199
+ const subItems = submenuItemsMap.get(parentId) ?? [];
200
+ const typeaheadItems = buildTypeaheadItems(subItems);
201
+ const currentId = submenuActiveIdAtom();
202
+ const startIndex = currentId ? typeaheadItems.findIndex((item) => item.id === currentId) + 1 : 0;
203
+ const matchId = findTypeaheadMatch(query, typeaheadItems, startIndex % typeaheadItems.length);
204
+ if (matchId != null) {
205
+ submenuActiveIdAtom.set(matchId);
206
+ }
207
+ }
208
+ else {
209
+ const typeaheadItems = buildTypeaheadItems(options.items);
210
+ const currentId = activeIdAtom();
211
+ const startIndex = currentId ? typeaheadItems.findIndex((item) => item.id === currentId) + 1 : 0;
212
+ const matchId = findTypeaheadMatch(query, typeaheadItems, startIndex % typeaheadItems.length);
213
+ if (matchId != null) {
214
+ activeIdAtom.set(matchId);
215
+ }
216
+ }
217
+ }, `${idBase}.handleTypeahead`);
218
+ const handleTriggerKeyDown = action((event) => {
219
+ if (event.key === 'ArrowDown') {
220
+ open('keyboard');
221
+ activeIdAtom.set(enabledIds[0] ?? null);
222
+ return;
223
+ }
224
+ if (event.key === 'ArrowUp') {
225
+ open('keyboard');
226
+ activeIdAtom.set(enabledIds[enabledIds.length - 1] ?? null);
227
+ return;
228
+ }
229
+ if (event.key === 'Enter' || event.key === ' ') {
230
+ toggle('keyboard');
231
+ }
232
+ }, `${idBase}.handleTriggerKeyDown`);
233
+ const handleMenuKeyDown = action((event) => {
234
+ if (!isOpenAtom())
235
+ return;
236
+ const submenuOpen = openSubmenuIdAtom() != null;
237
+ const typeaheadEvent = {
238
+ key: event.key,
239
+ shiftKey: event.shiftKey ?? false,
240
+ ctrlKey: event.ctrlKey ?? false,
241
+ metaKey: event.metaKey ?? false,
242
+ altKey: event.altKey ?? false,
243
+ };
244
+ if (submenuOpen) {
245
+ if (event.key === 'Escape') {
246
+ closeSubmenu();
247
+ return;
248
+ }
249
+ if (event.key === 'ArrowLeft') {
250
+ closeSubmenu();
251
+ return;
252
+ }
253
+ if (event.key === 'ArrowDown') {
254
+ moveSubmenu(1);
255
+ return;
256
+ }
257
+ if (event.key === 'ArrowUp') {
258
+ moveSubmenu(-1);
259
+ return;
260
+ }
261
+ if (event.key === 'Home') {
262
+ const parentId = openSubmenuIdAtom();
263
+ if (parentId) {
264
+ const enabled = submenuEnabledIds.get(parentId);
265
+ submenuActiveIdAtom.set(enabled?.[0] ?? null);
266
+ }
267
+ return;
268
+ }
269
+ if (event.key === 'End') {
270
+ const parentId = openSubmenuIdAtom();
271
+ if (parentId) {
272
+ const enabled = submenuEnabledIds.get(parentId);
273
+ submenuActiveIdAtom.set(enabled?.[enabled.length - 1] ?? null);
274
+ }
275
+ return;
276
+ }
277
+ if (event.key === 'Enter' || event.key === ' ') {
278
+ const subActiveId = submenuActiveIdAtom();
279
+ if (subActiveId != null) {
280
+ select(subActiveId);
281
+ }
282
+ return;
283
+ }
284
+ if (isTypeaheadEvent(typeaheadEvent)) {
285
+ handleTypeahead(event.key);
286
+ return;
287
+ }
288
+ return;
289
+ }
290
+ if (event.key === 'ArrowRight') {
291
+ const currentActiveId = activeIdAtom();
292
+ if (currentActiveId != null) {
293
+ const item = itemById.get(currentActiveId);
294
+ if (item?.hasSubmenu) {
295
+ openSubmenu(currentActiveId);
296
+ }
297
+ }
298
+ return;
299
+ }
300
+ if (event.key === 'ArrowLeft') {
301
+ return;
302
+ }
303
+ if (typeaheadEnabled && isTypeaheadEvent(typeaheadEvent)) {
304
+ handleTypeahead(event.key);
305
+ return;
306
+ }
307
+ const intent = mapListboxKeyboardIntent(typeaheadEvent, {
308
+ orientation: 'vertical',
309
+ selectionMode: 'single',
310
+ rangeSelectionEnabled: false,
311
+ });
312
+ if (intent == null)
313
+ return;
314
+ switch (intent) {
315
+ case 'NAV_NEXT':
316
+ moveNext();
317
+ return;
318
+ case 'NAV_PREV':
319
+ movePrev();
320
+ return;
321
+ case 'NAV_FIRST':
322
+ moveFirst();
323
+ return;
324
+ case 'NAV_LAST':
325
+ moveLast();
326
+ return;
327
+ case 'ACTIVATE': {
328
+ const activeId = activeIdAtom();
329
+ if (activeId != null) {
330
+ select(activeId);
331
+ }
332
+ return;
333
+ }
334
+ case 'TOGGLE_SELECTION': {
335
+ const activeId = activeIdAtom();
336
+ if (activeId != null) {
337
+ select(activeId);
338
+ }
339
+ return;
340
+ }
341
+ case 'DISMISS':
342
+ close();
343
+ return;
344
+ default:
345
+ return;
346
+ }
347
+ }, `${idBase}.handleMenuKeyDown`);
348
+ const cancelHoverIntent = () => {
349
+ if (hoverIntentTimer != null) {
350
+ clearTimeout(hoverIntentTimer);
351
+ hoverIntentTimer = null;
352
+ hoverIntentTargetId = null;
353
+ }
354
+ };
355
+ const handleItemPointerEnter = action((id) => {
356
+ if (enabledIds.includes(id)) {
357
+ activeIdAtom.set(id);
358
+ }
359
+ const item = itemById.get(id);
360
+ if (item?.hasSubmenu) {
361
+ cancelHoverIntent();
362
+ hoverIntentTargetId = id;
363
+ hoverIntentTimer = setTimeout(() => {
364
+ openSubmenu(id);
365
+ hoverIntentTimer = null;
366
+ hoverIntentTargetId = null;
367
+ }, 200);
368
+ }
369
+ else {
370
+ cancelHoverIntent();
371
+ if (openSubmenuIdAtom() != null) {
372
+ closeSubmenu();
373
+ }
374
+ }
375
+ }, `${idBase}.handleItemPointerEnter`);
376
+ const handleItemPointerLeave = action((id) => {
377
+ if (hoverIntentTargetId === id) {
378
+ cancelHoverIntent();
379
+ }
380
+ }, `${idBase}.handleItemPointerLeave`);
381
+ const setSubmenuItems = (parentId, items) => {
382
+ rebuildSubmenuMaps(parentId, items);
383
+ };
384
+ const actions = {
385
+ open,
386
+ close,
387
+ toggle,
388
+ setActive: setActiveAction,
389
+ moveNext,
390
+ movePrev,
391
+ moveFirst,
392
+ moveLast,
393
+ select,
394
+ toggleCheck,
395
+ openSubmenu,
396
+ closeSubmenu,
397
+ handleTypeahead,
398
+ handleTriggerKeyDown,
399
+ handleMenuKeyDown,
400
+ handleItemPointerEnter,
401
+ handleItemPointerLeave,
402
+ setSubmenuItems,
403
+ };
404
+ if (isOpenAtom()) {
405
+ setInitialActive();
406
+ }
407
+ const getItemRole = (item) => {
408
+ if (item.type === 'checkbox')
409
+ return 'menuitemcheckbox';
410
+ if (item.type === 'radio')
411
+ return 'menuitemradio';
412
+ return 'menuitem';
413
+ };
414
+ const contracts = {
415
+ getTriggerProps() {
416
+ if (splitButton) {
417
+ return this.getSplitDropdownProps();
418
+ }
419
+ return {
420
+ id: `${idBase}-trigger`,
421
+ tabindex: '0',
422
+ 'aria-haspopup': 'menu',
423
+ 'aria-expanded': isOpenAtom() ? 'true' : 'false',
424
+ 'aria-controls': menuId,
425
+ 'aria-label': options.ariaLabel,
426
+ };
427
+ },
428
+ getMenuProps() {
429
+ const activeId = activeIdAtom();
430
+ const result = {
431
+ id: menuId,
432
+ role: 'menu',
433
+ tabindex: '-1',
434
+ 'aria-label': options.ariaLabel,
435
+ };
436
+ if (isOpenAtom() && activeId != null) {
437
+ result['aria-activedescendant'] = itemDomId(activeId);
438
+ }
439
+ return result;
440
+ },
441
+ getItemProps(id) {
442
+ const item = itemById.get(id);
443
+ if (!item) {
444
+ throw new Error(`Unknown menu item id: ${id}`);
445
+ }
446
+ const role = getItemRole(item);
447
+ const checked = checkedIdsAtom();
448
+ const result = {
449
+ id: itemDomId(id),
450
+ role,
451
+ tabindex: '-1',
452
+ 'aria-disabled': item.disabled ? 'true' : undefined,
453
+ 'data-active': activeIdAtom() === id ? 'true' : 'false',
454
+ };
455
+ if (item.type === 'checkbox' || item.type === 'radio') {
456
+ result['aria-checked'] = checked.has(id) ? 'true' : 'false';
457
+ }
458
+ if (item.hasSubmenu) {
459
+ result['aria-haspopup'] = 'menu';
460
+ result['aria-expanded'] = openSubmenuIdAtom() === id ? 'true' : 'false';
461
+ }
462
+ return result;
463
+ },
464
+ getSubmenuProps(parentItemId) {
465
+ const parentItem = itemById.get(parentItemId);
466
+ return {
467
+ id: `${idBase}-submenu-${parentItemId}`,
468
+ role: 'menu',
469
+ tabindex: '-1',
470
+ hidden: openSubmenuIdAtom() !== parentItemId,
471
+ 'aria-label': parentItem?.label,
472
+ };
473
+ },
474
+ getSubmenuItemProps(parentItemId, childId) {
475
+ const item = submenuItemById.get(childId);
476
+ if (!item) {
477
+ throw new Error(`Unknown submenu item id: ${childId}`);
478
+ }
479
+ const role = getItemRole(item);
480
+ const checked = checkedIdsAtom();
481
+ const result = {
482
+ id: `${idBase}-item-${childId}`,
483
+ role,
484
+ tabindex: '-1',
485
+ 'aria-disabled': item.disabled ? 'true' : undefined,
486
+ 'data-active': submenuActiveIdAtom() === childId ? 'true' : 'false',
487
+ };
488
+ if (item.type === 'checkbox' || item.type === 'radio') {
489
+ result['aria-checked'] = checked.has(childId) ? 'true' : 'false';
490
+ }
491
+ return result;
492
+ },
493
+ getSplitTriggerProps() {
494
+ if (!splitButton) {
495
+ throw new Error('getSplitTriggerProps requires splitButton option to be true');
496
+ }
497
+ return {
498
+ id: `${idBase}-split-action`,
499
+ tabindex: '0',
500
+ role: 'button',
501
+ };
502
+ },
503
+ getSplitDropdownProps() {
504
+ if (!splitButton) {
505
+ throw new Error('getSplitDropdownProps requires splitButton option to be true');
506
+ }
507
+ return {
508
+ id: `${idBase}-split-dropdown`,
509
+ tabindex: '0',
510
+ role: 'button',
511
+ 'aria-haspopup': 'menu',
512
+ 'aria-expanded': isOpenAtom() ? 'true' : 'false',
513
+ 'aria-controls': menuId,
514
+ 'aria-label': options.ariaLabel ?? 'More options',
515
+ };
516
+ },
517
+ getGroupProps(groupId) {
518
+ const group = groupById.get(groupId);
519
+ return {
520
+ id: `${idBase}-group-${groupId}`,
521
+ role: 'group',
522
+ 'aria-label': group?.label,
523
+ };
524
+ },
525
+ };
526
+ const state = {
527
+ isOpen: isOpenAtom,
528
+ activeId: activeIdAtom,
529
+ selectedId: selectedIdAtom,
530
+ openedBy: openedByAtom,
531
+ hasSelection: hasSelectionAtom,
532
+ checkedIds: checkedIdsAtom,
533
+ openSubmenuId: openSubmenuIdAtom,
534
+ submenuActiveId: submenuActiveIdAtom,
535
+ };
536
+ return {
537
+ state,
538
+ actions,
539
+ contracts,
540
+ };
541
+ }