@foldkit/ui 0.112.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 (201) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +67 -0
  3. package/dist/anchor.d.ts +38 -0
  4. package/dist/anchor.d.ts.map +1 -0
  5. package/dist/anchor.js +142 -0
  6. package/dist/animation/index.d.ts +49 -0
  7. package/dist/animation/index.d.ts.map +1 -0
  8. package/dist/animation/index.js +75 -0
  9. package/dist/animation/public.d.ts +3 -0
  10. package/dist/animation/public.d.ts.map +1 -0
  11. package/dist/animation/public.js +1 -0
  12. package/dist/animation/schema.d.ts +43 -0
  13. package/dist/animation/schema.d.ts.map +1 -0
  14. package/dist/animation/schema.js +41 -0
  15. package/dist/animation/update.d.ts +24 -0
  16. package/dist/animation/update.d.ts.map +1 -0
  17. package/dist/animation/update.js +67 -0
  18. package/dist/button/index.d.ts +17 -0
  19. package/dist/button/index.d.ts.map +1 -0
  20. package/dist/button/index.js +22 -0
  21. package/dist/button/public.d.ts +3 -0
  22. package/dist/button/public.d.ts.map +1 -0
  23. package/dist/button/public.js +1 -0
  24. package/dist/calendar/index.d.ts +462 -0
  25. package/dist/calendar/index.d.ts.map +1 -0
  26. package/dist/calendar/index.js +825 -0
  27. package/dist/calendar/public.d.ts +3 -0
  28. package/dist/calendar/public.d.ts.map +1 -0
  29. package/dist/calendar/public.js +1 -0
  30. package/dist/checkbox/index.d.ts +119 -0
  31. package/dist/checkbox/index.d.ts.map +1 -0
  32. package/dist/checkbox/index.js +111 -0
  33. package/dist/checkbox/public.d.ts +3 -0
  34. package/dist/checkbox/public.d.ts.map +1 -0
  35. package/dist/checkbox/public.js +1 -0
  36. package/dist/combobox/multi.d.ts +183 -0
  37. package/dist/combobox/multi.d.ts.map +1 -0
  38. package/dist/combobox/multi.js +81 -0
  39. package/dist/combobox/multiPublic.d.ts +3 -0
  40. package/dist/combobox/multiPublic.d.ts.map +1 -0
  41. package/dist/combobox/multiPublic.js +1 -0
  42. package/dist/combobox/public.d.ts +7 -0
  43. package/dist/combobox/public.d.ts.map +1 -0
  44. package/dist/combobox/public.js +3 -0
  45. package/dist/combobox/shared.d.ts +423 -0
  46. package/dist/combobox/shared.d.ts.map +1 -0
  47. package/dist/combobox/shared.js +708 -0
  48. package/dist/combobox/single.d.ts +198 -0
  49. package/dist/combobox/single.d.ts.map +1 -0
  50. package/dist/combobox/single.js +106 -0
  51. package/dist/datePicker/index.d.ts +457 -0
  52. package/dist/datePicker/index.d.ts.map +1 -0
  53. package/dist/datePicker/index.js +318 -0
  54. package/dist/datePicker/public.d.ts +3 -0
  55. package/dist/datePicker/public.d.ts.map +1 -0
  56. package/dist/datePicker/public.js +1 -0
  57. package/dist/dialog/index.d.ts +160 -0
  58. package/dist/dialog/index.d.ts.map +1 -0
  59. package/dist/dialog/index.js +211 -0
  60. package/dist/dialog/public.d.ts +3 -0
  61. package/dist/dialog/public.d.ts.map +1 -0
  62. package/dist/dialog/public.js +1 -0
  63. package/dist/disclosure/index.d.ts +110 -0
  64. package/dist/disclosure/index.d.ts.map +1 -0
  65. package/dist/disclosure/index.js +111 -0
  66. package/dist/disclosure/public.d.ts +3 -0
  67. package/dist/disclosure/public.d.ts.map +1 -0
  68. package/dist/disclosure/public.js +1 -0
  69. package/dist/dragAndDrop/index.d.ts +540 -0
  70. package/dist/dragAndDrop/index.d.ts.map +1 -0
  71. package/dist/dragAndDrop/index.js +535 -0
  72. package/dist/dragAndDrop/public.d.ts +3 -0
  73. package/dist/dragAndDrop/public.d.ts.map +1 -0
  74. package/dist/dragAndDrop/public.js +1 -0
  75. package/dist/fieldset/index.d.ts +21 -0
  76. package/dist/fieldset/index.d.ts.map +1 -0
  77. package/dist/fieldset/index.js +25 -0
  78. package/dist/fieldset/public.d.ts +3 -0
  79. package/dist/fieldset/public.d.ts.map +1 -0
  80. package/dist/fieldset/public.js +1 -0
  81. package/dist/fileDrop/index.d.ts +109 -0
  82. package/dist/fileDrop/index.d.ts.map +1 -0
  83. package/dist/fileDrop/index.js +127 -0
  84. package/dist/fileDrop/public.d.ts +3 -0
  85. package/dist/fileDrop/public.d.ts.map +1 -0
  86. package/dist/fileDrop/public.js +1 -0
  87. package/dist/group.d.ts +8 -0
  88. package/dist/group.d.ts.map +1 -0
  89. package/dist/group.js +13 -0
  90. package/dist/index.d.ts +25 -0
  91. package/dist/index.d.ts.map +1 -0
  92. package/dist/index.js +24 -0
  93. package/dist/input/index.d.ts +26 -0
  94. package/dist/input/index.d.ts.map +1 -0
  95. package/dist/input/index.js +43 -0
  96. package/dist/input/public.d.ts +3 -0
  97. package/dist/input/public.d.ts.map +1 -0
  98. package/dist/input/public.js +1 -0
  99. package/dist/internal/optionExtensions.d.ts +6 -0
  100. package/dist/internal/optionExtensions.d.ts.map +1 -0
  101. package/dist/internal/optionExtensions.js +2 -0
  102. package/dist/keyboard.d.ts +6 -0
  103. package/dist/keyboard.d.ts.map +1 -0
  104. package/dist/keyboard.js +9 -0
  105. package/dist/listbox/multi.d.ts +189 -0
  106. package/dist/listbox/multi.d.ts.map +1 -0
  107. package/dist/listbox/multi.js +65 -0
  108. package/dist/listbox/multiPublic.d.ts +3 -0
  109. package/dist/listbox/multiPublic.d.ts.map +1 -0
  110. package/dist/listbox/multiPublic.js +1 -0
  111. package/dist/listbox/public.d.ts +7 -0
  112. package/dist/listbox/public.d.ts.map +1 -0
  113. package/dist/listbox/public.js +3 -0
  114. package/dist/listbox/shared.d.ts +432 -0
  115. package/dist/listbox/shared.d.ts.map +1 -0
  116. package/dist/listbox/shared.js +670 -0
  117. package/dist/listbox/single.d.ts +207 -0
  118. package/dist/listbox/single.d.ts.map +1 -0
  119. package/dist/listbox/single.js +73 -0
  120. package/dist/menu/index.d.ts +368 -0
  121. package/dist/menu/index.d.ts.map +1 -0
  122. package/dist/menu/index.js +682 -0
  123. package/dist/menu/public.d.ts +4 -0
  124. package/dist/menu/public.d.ts.map +1 -0
  125. package/dist/menu/public.js +1 -0
  126. package/dist/popover/index.d.ts +267 -0
  127. package/dist/popover/index.d.ts.map +1 -0
  128. package/dist/popover/index.js +346 -0
  129. package/dist/popover/public.d.ts +4 -0
  130. package/dist/popover/public.d.ts.map +1 -0
  131. package/dist/popover/public.js +1 -0
  132. package/dist/radioGroup/index.d.ts +169 -0
  133. package/dist/radioGroup/index.d.ts.map +1 -0
  134. package/dist/radioGroup/index.js +197 -0
  135. package/dist/radioGroup/public.d.ts +3 -0
  136. package/dist/radioGroup/public.d.ts.map +1 -0
  137. package/dist/radioGroup/public.js +1 -0
  138. package/dist/select/index.d.ts +24 -0
  139. package/dist/select/index.d.ts.map +1 -0
  140. package/dist/select/index.js +40 -0
  141. package/dist/select/public.d.ts +3 -0
  142. package/dist/select/public.d.ts.map +1 -0
  143. package/dist/select/public.js +1 -0
  144. package/dist/slider/index.d.ts +318 -0
  145. package/dist/slider/index.d.ts.map +1 -0
  146. package/dist/slider/index.js +337 -0
  147. package/dist/slider/public.d.ts +3 -0
  148. package/dist/slider/public.d.ts.map +1 -0
  149. package/dist/slider/public.js +1 -0
  150. package/dist/switch/index.d.ts +99 -0
  151. package/dist/switch/index.d.ts.map +1 -0
  152. package/dist/switch/index.js +107 -0
  153. package/dist/switch/public.d.ts +3 -0
  154. package/dist/switch/public.d.ts.map +1 -0
  155. package/dist/switch/public.js +1 -0
  156. package/dist/tabs/index.d.ts +155 -0
  157. package/dist/tabs/index.d.ts.map +1 -0
  158. package/dist/tabs/index.js +185 -0
  159. package/dist/tabs/public.d.ts +3 -0
  160. package/dist/tabs/public.d.ts.map +1 -0
  161. package/dist/tabs/public.js +1 -0
  162. package/dist/test/apps/disabledButton.d.ts +38 -0
  163. package/dist/test/apps/disabledButton.d.ts.map +1 -0
  164. package/dist/test/apps/disabledButton.js +71 -0
  165. package/dist/textarea/index.d.ts +26 -0
  166. package/dist/textarea/index.d.ts.map +1 -0
  167. package/dist/textarea/index.js +44 -0
  168. package/dist/textarea/public.d.ts +3 -0
  169. package/dist/textarea/public.d.ts.map +1 -0
  170. package/dist/textarea/public.js +1 -0
  171. package/dist/toast/index.d.ts +608 -0
  172. package/dist/toast/index.d.ts.map +1 -0
  173. package/dist/toast/index.js +146 -0
  174. package/dist/toast/public.d.ts +4 -0
  175. package/dist/toast/public.d.ts.map +1 -0
  176. package/dist/toast/public.js +1 -0
  177. package/dist/toast/schema.d.ts +154 -0
  178. package/dist/toast/schema.d.ts.map +1 -0
  179. package/dist/toast/schema.js +93 -0
  180. package/dist/toast/update.d.ts +510 -0
  181. package/dist/toast/update.d.ts.map +1 -0
  182. package/dist/toast/update.js +225 -0
  183. package/dist/tooltip/index.d.ts +170 -0
  184. package/dist/tooltip/index.d.ts.map +1 -0
  185. package/dist/tooltip/index.js +253 -0
  186. package/dist/tooltip/public.d.ts +4 -0
  187. package/dist/tooltip/public.d.ts.map +1 -0
  188. package/dist/tooltip/public.js +1 -0
  189. package/dist/typeahead.d.ts +4 -0
  190. package/dist/typeahead.d.ts.map +1 -0
  191. package/dist/typeahead.js +14 -0
  192. package/dist/virtualList/index.d.ts +203 -0
  193. package/dist/virtualList/index.d.ts.map +1 -0
  194. package/dist/virtualList/index.js +392 -0
  195. package/dist/virtualList/public.d.ts +3 -0
  196. package/dist/virtualList/public.d.ts.map +1 -0
  197. package/dist/virtualList/public.js +1 -0
  198. package/dist/vitest-setup.d.ts +2 -0
  199. package/dist/vitest-setup.d.ts.map +1 -0
  200. package/dist/vitest-setup.js +2 -0
  201. package/package.json +161 -0
@@ -0,0 +1,682 @@
1
+ import { Array, Effect, Equal, Match as M, Option, Predicate, Schema as S, String as Str, pipe, } from 'effect';
2
+ import * as Command from 'foldkit/command';
3
+ import * as Dom from 'foldkit/dom';
4
+ import { html } from 'foldkit/html';
5
+ import { m } from 'foldkit/message';
6
+ import * as Mount from 'foldkit/mount';
7
+ import { evo } from 'foldkit/struct';
8
+ import { defineView } from 'foldkit/submodel';
9
+ import { AnchorConfig, anchorSetup, portalToBody } from '../anchor.js';
10
+ // NOTE: Animation imports are split across schema + update to avoid a circular
11
+ // dependency: animation → html → runtime → devtools → menu → animation.
12
+ // The barrel (../animation) imports from html, which starts the cycle.
13
+ import { EndedAnimation as AnimationEndedAnimation, Hid as AnimationHid, Message as AnimationMessage, Model as AnimationModel, Showed as AnimationShowed, init as animationInit, } from '../animation/schema.js';
14
+ import { update as animationUpdate } from '../animation/update.js';
15
+ import { groupContiguous } from '../group.js';
16
+ import * as OptionExt from '../internal/optionExtensions.js';
17
+ import { findFirstEnabledIndex, isPrintableKey, keyToIndex, } from '../keyboard.js';
18
+ import { resolveTypeaheadMatch } from '../typeahead.js';
19
+ // MODEL
20
+ /** Schema for the activation trigger: whether the user interacted via mouse or keyboard. */
21
+ export const ActivationTrigger = S.Literals(['Pointer', 'Keyboard']);
22
+ const PointerOrigin = S.Struct({
23
+ screenX: S.Number,
24
+ screenY: S.Number,
25
+ timeStamp: S.Number,
26
+ });
27
+ /** Schema for the menu component's state, tracking open/closed status, active item, activation trigger, and typeahead search. */
28
+ export const Model = S.Struct({
29
+ id: S.String,
30
+ isOpen: S.Boolean,
31
+ isAnimated: S.Boolean,
32
+ isModal: S.Boolean,
33
+ animation: AnimationModel,
34
+ maybeActiveItemIndex: S.Option(S.Number),
35
+ activationTrigger: ActivationTrigger,
36
+ searchQuery: S.String,
37
+ searchVersion: S.Number,
38
+ maybeLastPointerPosition: S.Option(S.Struct({ screenX: S.Number, screenY: S.Number })),
39
+ maybeLastButtonPointerType: S.Option(S.String),
40
+ maybePointerOrigin: S.Option(PointerOrigin),
41
+ });
42
+ // MESSAGE
43
+ /** Sent when the menu opens via button click or keyboard. Contains an optional initial active item index: None for pointer, Some for keyboard. */
44
+ export const Opened = m('Opened', {
45
+ maybeActiveItemIndex: S.Option(S.Number),
46
+ });
47
+ /** Sent when the menu closes via Escape key or backdrop click. */
48
+ export const Closed = m('Closed');
49
+ /** Sent when the menu items container loses focus. */
50
+ export const BlurredItems = m('BlurredItems');
51
+ /** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger. */
52
+ export const ActivatedItem = m('ActivatedItem', {
53
+ index: S.Number,
54
+ activationTrigger: ActivationTrigger,
55
+ });
56
+ /** Sent when the mouse leaves an enabled item. */
57
+ export const DeactivatedItem = m('DeactivatedItem');
58
+ /** Sent when an item is selected via Enter, Space, or click. */
59
+ export const SelectedItem = m('SelectedItem', {
60
+ index: S.Number,
61
+ item: S.String,
62
+ });
63
+ /** Sent when Enter or Space is pressed on the active item, triggering a programmatic click on the DOM element. */
64
+ export const RequestedItemClick = m('RequestedItemClick', {
65
+ index: S.Number,
66
+ });
67
+ /** Sent when a printable character is typed for typeahead search. */
68
+ export const Searched = m('Searched', {
69
+ key: S.String,
70
+ maybeTargetIndex: S.Option(S.Number),
71
+ });
72
+ /** Sent after the search debounce period to clear the accumulated query. */
73
+ export const ClearedSearch = m('ClearedSearch', { version: S.Number });
74
+ /** Sent when the pointer moves over a menu item, carrying screen coordinates for tracked-pointer comparison. */
75
+ export const MovedPointerOverItem = m('MovedPointerOverItem', {
76
+ index: S.Number,
77
+ screenX: S.Number,
78
+ screenY: S.Number,
79
+ });
80
+ /** Sent when the focus-items command completes after opening the menu. */
81
+ export const CompletedFocusItems = m('CompletedFocusItems');
82
+ /** Sent when the focus-button command completes after closing or selecting. */
83
+ export const CompletedFocusButton = m('CompletedFocusButton');
84
+ /** Sent when the scroll lock command completes. */
85
+ export const CompletedLockScroll = m('CompletedLockScroll');
86
+ /** Sent when the scroll unlock command completes. */
87
+ export const CompletedUnlockScroll = m('CompletedUnlockScroll');
88
+ /** Sent when the inert-others command completes. */
89
+ export const CompletedInertOthers = m('CompletedInertOthers');
90
+ /** Sent when the restore-inert command completes. */
91
+ export const CompletedRestoreInert = m('CompletedRestoreInert');
92
+ /** Sent when the scroll-into-view command completes after keyboard activation. */
93
+ export const CompletedScrollIntoView = m('CompletedScrollIntoView');
94
+ /** Sent when the programmatic click command completes. */
95
+ export const CompletedClickItem = m('CompletedClickItem');
96
+ /** Sent when a mouse click on the button is ignored because pointer-down already handled the toggle. */
97
+ export const IgnoredMouseClick = m('IgnoredMouseClick');
98
+ /** Sent when a Space key-up is captured to prevent page scrolling. */
99
+ export const SuppressedSpaceScroll = m('SuppressedSpaceScroll');
100
+ /** Sent when the menu items panel mounts and Floating UI has positioned it. Update no-ops; the side effect is the act of positioning, surfaced for DevTools observability. */
101
+ export const CompletedAnchorMenu = m('CompletedAnchorMenu');
102
+ /** Sent when the menu backdrop mounts and is portaled to the document body. Update no-ops; surfaces the portal side effect for DevTools. */
103
+ export const CompletedPortalMenuBackdrop = m('CompletedPortalMenuBackdrop');
104
+ /** Wraps an Animation submodel message for delegation. */
105
+ export const GotAnimationMessage = m('GotAnimationMessage', {
106
+ message: AnimationMessage,
107
+ });
108
+ /** Sent when the user presses a pointer device on the menu button. Records pointer type and toggles for mouse. */
109
+ export const PressedPointerOnButton = m('PressedPointerOnButton', {
110
+ pointerType: S.String,
111
+ button: S.Number,
112
+ screenX: S.Number,
113
+ screenY: S.Number,
114
+ timeStamp: S.Number,
115
+ });
116
+ /** Sent when the user releases a pointer on the items container, enabling drag-to-select for mouse. */
117
+ export const ReleasedPointerOnItems = m('ReleasedPointerOnItems', {
118
+ screenX: S.Number,
119
+ screenY: S.Number,
120
+ timeStamp: S.Number,
121
+ });
122
+ /** Union of all messages the menu component can produce. */
123
+ export const Message = S.Union([
124
+ Opened,
125
+ Closed,
126
+ BlurredItems,
127
+ ActivatedItem,
128
+ DeactivatedItem,
129
+ SelectedItem,
130
+ MovedPointerOverItem,
131
+ RequestedItemClick,
132
+ Searched,
133
+ ClearedSearch,
134
+ CompletedFocusItems,
135
+ CompletedFocusButton,
136
+ CompletedLockScroll,
137
+ CompletedUnlockScroll,
138
+ CompletedInertOthers,
139
+ CompletedRestoreInert,
140
+ CompletedScrollIntoView,
141
+ CompletedClickItem,
142
+ IgnoredMouseClick,
143
+ SuppressedSpaceScroll,
144
+ CompletedAnchorMenu,
145
+ CompletedPortalMenuBackdrop,
146
+ GotAnimationMessage,
147
+ PressedPointerOnButton,
148
+ ReleasedPointerOnItems,
149
+ ]);
150
+ // OUT MESSAGE
151
+ /** Sent to the parent when a menu item is selected. Carries both the selected value (from the `viewInputs.items` array supplied at view time) and its index. The menu has already closed when this fires; the parent does not need to dispatch `Menu.close`. */
152
+ export const Selected = m('Selected', {
153
+ value: S.String,
154
+ index: S.Number,
155
+ });
156
+ /** Union of out-messages the menu component can produce. Surfaced as the third element of `update`'s return tuple and pattern-matched by the parent. */
157
+ export const OutMessage = S.Union([Selected]);
158
+ // INIT
159
+ const SEARCH_DEBOUNCE_MILLISECONDS = 350;
160
+ const LEFT_MOUSE_BUTTON = 0;
161
+ const POINTER_HOLD_THRESHOLD_MILLISECONDS = 200;
162
+ const POINTER_MOVEMENT_THRESHOLD_PIXELS = 5;
163
+ /** Creates an initial menu model from a config. Defaults to closed with no active item. */
164
+ export const init = (config) => ({
165
+ id: config.id,
166
+ isOpen: false,
167
+ isAnimated: config.isAnimated ?? false,
168
+ isModal: config.isModal ?? false,
169
+ animation: animationInit({ id: `${config.id}-items` }),
170
+ maybeActiveItemIndex: Option.none(),
171
+ activationTrigger: 'Keyboard',
172
+ searchQuery: '',
173
+ searchVersion: 0,
174
+ maybeLastPointerPosition: Option.none(),
175
+ maybeLastButtonPointerType: Option.none(),
176
+ maybePointerOrigin: Option.none(),
177
+ });
178
+ // UPDATE
179
+ const closedModel = (model) => evo(model, {
180
+ isOpen: () => false,
181
+ maybeActiveItemIndex: () => Option.none(),
182
+ searchQuery: () => '',
183
+ searchVersion: () => 0,
184
+ maybeLastPointerPosition: () => Option.none(),
185
+ maybeLastButtonPointerType: () => Option.none(),
186
+ maybePointerOrigin: () => Option.none(),
187
+ });
188
+ const buttonSelector = (id) => `#${id}-button`;
189
+ const itemsSelector = (id) => `#${id}-items`;
190
+ const itemSelector = (id, index) => `#${id}-item-${index}`;
191
+ const withUpdateReturn = M.withReturnType();
192
+ /** Prevents page scrolling while the menu is open. */
193
+ export const LockScroll = Command.define('LockScroll', CompletedLockScroll)(Dom.lockScroll.pipe(Effect.as(CompletedLockScroll())));
194
+ /** Re-enables page scrolling after the menu closes. */
195
+ export const UnlockScroll = Command.define('UnlockScroll', CompletedUnlockScroll)(Dom.unlockScroll.pipe(Effect.as(CompletedUnlockScroll())));
196
+ /** Marks all elements outside the menu as inert for modal behavior. */
197
+ export const InertOthers = Command.define('InertOthers', { id: S.String }, CompletedInertOthers)(({ id }) => Dom.inertOthers(id, [buttonSelector(id), itemsSelector(id)]).pipe(Effect.as(CompletedInertOthers())));
198
+ /** Removes the inert attribute from elements outside the menu. */
199
+ export const RestoreInert = Command.define('RestoreInert', { id: S.String }, CompletedRestoreInert)(({ id }) => Dom.restoreInert(id).pipe(Effect.as(CompletedRestoreInert())));
200
+ /** Moves focus to the menu items container after opening. */
201
+ export const FocusItems = Command.define('FocusItems', { id: S.String }, CompletedFocusItems)(({ id }) => Dom.focus(itemsSelector(id)).pipe(Effect.ignore, Effect.as(CompletedFocusItems())));
202
+ /** Moves focus back to the menu button after closing. */
203
+ export const FocusButton = Command.define('FocusButton', { id: S.String }, CompletedFocusButton)(({ id }) => Dom.focus(buttonSelector(id)).pipe(Effect.ignore, Effect.as(CompletedFocusButton())));
204
+ /** Scrolls the active menu item into view after keyboard navigation. */
205
+ export const ScrollIntoView = Command.define('ScrollIntoView', { id: S.String, index: S.Number }, CompletedScrollIntoView)(({ id, index }) => Dom.scrollIntoView(itemSelector(id, index)).pipe(Effect.ignore, Effect.as(CompletedScrollIntoView())));
206
+ /** Programmatically clicks the active menu item's DOM element. */
207
+ export const ClickItem = Command.define('ClickItem', { id: S.String, index: S.Number }, CompletedClickItem)(({ id, index }) => Dom.clickElement(itemSelector(id, index)).pipe(Effect.ignore, Effect.as(CompletedClickItem())));
208
+ /** Waits for the typeahead search debounce period before clearing the query. */
209
+ export const DelayClearSearch = Command.define('DelayClearSearch', { version: S.Number }, ClearedSearch)(({ version }) => Effect.sleep(SEARCH_DEBOUNCE_MILLISECONDS).pipe(Effect.as(ClearedSearch({ version }))));
210
+ /** Detects whether the menu button moved or the leave animation ended. Whichever comes first; both outcomes signal the Animation submodel that leave is complete. */
211
+ export const DetectMovementOrAnimationEnd = Command.define('DetectMovementOrAnimationEnd', { id: S.String }, GotAnimationMessage)(({ id }) => Effect.raceFirst(Dom.detectElementMovement(buttonSelector(id)).pipe(Effect.as(GotAnimationMessage({ message: AnimationEndedAnimation() }))), Dom.waitForAnimationSettled(itemsSelector(id)).pipe(Effect.as(GotAnimationMessage({ message: AnimationEndedAnimation() })))));
212
+ const delegateToAnimation = (model, animationMessage) => {
213
+ const [nextAnimation, animationCommands, maybeOutMessage] = animationUpdate(model.animation, animationMessage);
214
+ const mappedCommands = Command.mapMessages(animationCommands, message => GotAnimationMessage({ message }));
215
+ const additionalCommands = Option.match(maybeOutMessage, {
216
+ onNone: () => [],
217
+ onSome: M.type().pipe(M.tagsExhaustive({
218
+ StartedLeaveAnimating: () => [
219
+ DetectMovementOrAnimationEnd({ id: model.id }),
220
+ ],
221
+ TransitionedOut: () => [],
222
+ })),
223
+ });
224
+ return [
225
+ evo(model, { animation: () => nextAnimation }),
226
+ [...mappedCommands, ...additionalCommands],
227
+ Option.none(),
228
+ ];
229
+ };
230
+ /** Processes a menu message and returns the next model and commands. */
231
+ export const update = (model, message) => {
232
+ const maybeLockScroll = OptionExt.when(model.isModal, LockScroll());
233
+ const maybeUnlockScroll = OptionExt.when(model.isModal, UnlockScroll());
234
+ const maybeInertOthers = OptionExt.when(model.isModal, InertOthers({ id: model.id }));
235
+ const maybeRestoreInert = OptionExt.when(model.isModal, RestoreInert({ id: model.id }));
236
+ const openCommands = [
237
+ ...Array.getSomes([maybeLockScroll, maybeInertOthers]),
238
+ FocusItems({ id: model.id }),
239
+ ];
240
+ const closeWithFocusCommands = [
241
+ FocusButton({ id: model.id }),
242
+ ...Array.getSomes([maybeUnlockScroll, maybeRestoreInert]),
243
+ ];
244
+ const closeWithoutFocusCommands = Array.getSomes([
245
+ maybeUnlockScroll,
246
+ maybeRestoreInert,
247
+ ]);
248
+ const openMenu = (baseModel) => {
249
+ if (model.isAnimated) {
250
+ const [nextModel, animationCommands] = delegateToAnimation(baseModel, AnimationShowed());
251
+ return [
252
+ evo(nextModel, { isOpen: () => true }),
253
+ [...openCommands, ...animationCommands],
254
+ Option.none(),
255
+ ];
256
+ }
257
+ return [evo(baseModel, { isOpen: () => true }), openCommands, Option.none()];
258
+ };
259
+ const closeMenu = (baseModel, commands, maybeOutMessage = Option.none()) => {
260
+ const closed = closedModel(baseModel);
261
+ if (model.isAnimated) {
262
+ const [nextModel, animationCommands] = delegateToAnimation(closed, AnimationHid());
263
+ return [nextModel, [...commands, ...animationCommands], maybeOutMessage];
264
+ }
265
+ return [closed, commands, maybeOutMessage];
266
+ };
267
+ return M.value(message).pipe(withUpdateReturn, M.tag('CompletedFocusItems', 'CompletedFocusButton', 'CompletedLockScroll', 'CompletedUnlockScroll', 'CompletedInertOthers', 'CompletedRestoreInert', 'CompletedScrollIntoView', 'CompletedClickItem', 'SuppressedSpaceScroll', 'CompletedAnchorMenu', 'CompletedPortalMenuBackdrop', () => [model, [], Option.none()]), M.tagsExhaustive({
268
+ Opened: ({ maybeActiveItemIndex }) => openMenu(evo(model, {
269
+ maybeActiveItemIndex: () => maybeActiveItemIndex,
270
+ activationTrigger: () => Option.match(maybeActiveItemIndex, {
271
+ onNone: () => 'Pointer',
272
+ onSome: () => 'Keyboard',
273
+ }),
274
+ searchQuery: () => '',
275
+ searchVersion: () => 0,
276
+ maybeLastPointerPosition: () => Option.none(),
277
+ })),
278
+ Closed: () => closeMenu(model, closeWithFocusCommands),
279
+ BlurredItems: () => {
280
+ if (Option.exists(model.maybeLastButtonPointerType, Equal.equals('mouse'))) {
281
+ return [model, [], Option.none()];
282
+ }
283
+ return closeMenu(model, closeWithoutFocusCommands);
284
+ },
285
+ ActivatedItem: ({ index, activationTrigger }) => [
286
+ evo(model, {
287
+ maybeActiveItemIndex: () => Option.some(index),
288
+ activationTrigger: () => activationTrigger,
289
+ }),
290
+ activationTrigger === 'Keyboard'
291
+ ? [ScrollIntoView({ id: model.id, index })]
292
+ : [],
293
+ Option.none(),
294
+ ],
295
+ MovedPointerOverItem: ({ index, screenX, screenY }) => {
296
+ const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
297
+ if (isSamePosition) {
298
+ return [model, [], Option.none()];
299
+ }
300
+ return [
301
+ evo(model, {
302
+ maybeActiveItemIndex: () => Option.some(index),
303
+ activationTrigger: () => 'Pointer',
304
+ maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
305
+ }),
306
+ [],
307
+ Option.none(),
308
+ ];
309
+ },
310
+ DeactivatedItem: () => model.activationTrigger === 'Pointer'
311
+ ? [
312
+ evo(model, { maybeActiveItemIndex: () => Option.none() }),
313
+ [],
314
+ Option.none(),
315
+ ]
316
+ : [model, [], Option.none()],
317
+ SelectedItem: ({ index, item }) => closeMenu(model, closeWithFocusCommands, Option.some(Selected({ value: item, index }))),
318
+ RequestedItemClick: ({ index }) => [
319
+ model,
320
+ [ClickItem({ id: model.id, index })],
321
+ Option.none(),
322
+ ],
323
+ Searched: ({ key, maybeTargetIndex }) => {
324
+ const nextSearchQuery = model.searchQuery + key;
325
+ const nextSearchVersion = model.searchVersion + 1;
326
+ return [
327
+ evo(model, {
328
+ searchQuery: () => nextSearchQuery,
329
+ searchVersion: () => nextSearchVersion,
330
+ maybeActiveItemIndex: () => Option.orElse(maybeTargetIndex, () => model.maybeActiveItemIndex),
331
+ }),
332
+ [DelayClearSearch({ version: nextSearchVersion })],
333
+ Option.none(),
334
+ ];
335
+ },
336
+ ClearedSearch: ({ version }) => {
337
+ if (version !== model.searchVersion) {
338
+ return [model, [], Option.none()];
339
+ }
340
+ return [evo(model, { searchQuery: () => '' }), [], Option.none()];
341
+ },
342
+ GotAnimationMessage: ({ message: animationMessage }) => delegateToAnimation(model, animationMessage),
343
+ PressedPointerOnButton: ({ pointerType, button, screenX, screenY, timeStamp, }) => {
344
+ const withPointerType = evo(model, {
345
+ maybeLastButtonPointerType: () => Option.some(pointerType),
346
+ });
347
+ if (pointerType !== 'mouse' || button !== LEFT_MOUSE_BUTTON) {
348
+ return [withPointerType, [], Option.none()];
349
+ }
350
+ if (model.isOpen) {
351
+ const [closed, commands] = closeMenu(withPointerType, closeWithFocusCommands);
352
+ return [
353
+ evo(closed, {
354
+ maybeLastButtonPointerType: () => Option.some(pointerType),
355
+ }),
356
+ commands,
357
+ Option.none(),
358
+ ];
359
+ }
360
+ return openMenu(evo(withPointerType, {
361
+ maybeActiveItemIndex: () => Option.none(),
362
+ activationTrigger: () => 'Pointer',
363
+ searchQuery: () => '',
364
+ searchVersion: () => 0,
365
+ maybeLastPointerPosition: () => Option.none(),
366
+ maybePointerOrigin: () => Option.some({ screenX, screenY, timeStamp }),
367
+ }));
368
+ },
369
+ ReleasedPointerOnItems: ({ screenX, screenY, timeStamp }) => {
370
+ const hasNoOrigin = Option.isNone(model.maybePointerOrigin);
371
+ const hasNoActiveItem = Option.isNone(model.maybeActiveItemIndex);
372
+ const isMovementBelowThreshold = Option.exists(model.maybePointerOrigin, origin => Math.abs(screenX - origin.screenX) <
373
+ POINTER_MOVEMENT_THRESHOLD_PIXELS &&
374
+ Math.abs(screenY - origin.screenY) <
375
+ POINTER_MOVEMENT_THRESHOLD_PIXELS);
376
+ const isHoldTimeBelowThreshold = Option.exists(model.maybePointerOrigin, origin => timeStamp - origin.timeStamp < POINTER_HOLD_THRESHOLD_MILLISECONDS);
377
+ if (hasNoOrigin ||
378
+ isMovementBelowThreshold ||
379
+ isHoldTimeBelowThreshold ||
380
+ hasNoActiveItem) {
381
+ return [model, [], Option.none()];
382
+ }
383
+ return [
384
+ model,
385
+ [
386
+ ClickItem({
387
+ id: model.id,
388
+ index: model.maybeActiveItemIndex.value,
389
+ }),
390
+ ],
391
+ Option.none(),
392
+ ];
393
+ },
394
+ IgnoredMouseClick: () => [
395
+ evo(model, { maybeLastButtonPointerType: () => Option.none() }),
396
+ [],
397
+ Option.none(),
398
+ ],
399
+ }));
400
+ };
401
+ /** The anchor-positioning Mount this Menu renders on its panel. The panel is
402
+ * always anchored to the button via Floating UI and portaled to the document
403
+ * body (opt out of portaling with `anchor.portal: false`), so it escapes
404
+ * ancestor stacking contexts and overflow clipping. Exposed so Scene tests
405
+ * can call `Scene.Mount.resolve(AnchorMenu, CompletedAnchorMenu())`. */
406
+ export const AnchorMenu = Mount.define('AnchorMenu', { buttonId: S.String, anchor: AnchorConfig }, CompletedAnchorMenu)(({ buttonId, anchor }) => element => Effect.gen(function* () {
407
+ yield* Effect.acquireRelease(Effect.sync(() => anchorSetup({ buttonId, anchor })(element)), cleanup => Effect.sync(cleanup));
408
+ return CompletedAnchorMenu();
409
+ }));
410
+ /** The backdrop-portaling Mount this Menu renders. Exposed so Scene tests can
411
+ * call `Scene.Mount.resolve(PortalMenuBackdrop, CompletedPortalMenuBackdrop())` to
412
+ * acknowledge the mount produced by the rendered backdrop. */
413
+ export const PortalMenuBackdrop = Mount.define('PortalMenuBackdrop', CompletedPortalMenuBackdrop)(element => Effect.gen(function* () {
414
+ yield* Effect.acquireRelease(Effect.sync(() => portalToBody(element)), cleanup => Effect.sync(cleanup));
415
+ return CompletedPortalMenuBackdrop();
416
+ }));
417
+ /** Programmatically opens the menu, updating the model and returning
418
+ * focus and modal commands. Use this in domain-event handlers to open the menu. */
419
+ export const open = (model) => update(model, Opened({ maybeActiveItemIndex: Option.none() }));
420
+ /** Programmatically closes the menu, updating the model and returning
421
+ * focus and modal commands. Use this in domain-event handlers to close the menu. */
422
+ export const close = (model) => update(model, Closed());
423
+ /** Programmatically selects a menu item, closing the menu and returning
424
+ * focus commands plus a `Selected` OutMessage. Use this in domain-event handlers. */
425
+ export const selectItem = (model, item, index) => update(model, SelectedItem({ index, item }));
426
+ export { groupContiguous, resolveTypeaheadMatch };
427
+ const itemId = (id, index) => `${id}-item-${index}`;
428
+ const internalView = () =>
429
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
430
+ menuViewImpl;
431
+ const menuViewImpl = defineView((model, viewInputs) => {
432
+ const h = html();
433
+ const { id, isOpen, animation: { transitionState }, maybeActiveItemIndex, searchQuery, maybeLastButtonPointerType, } = model;
434
+ const { items, itemToConfig, isItemDisabled, itemToSearchText = (item) => item, isButtonDisabled, buttonContent, buttonClassName, buttonAttributes = [], itemsClassName, itemsAttributes = [], itemsScrollClassName, itemsScrollAttributes = [], backdropClassName, backdropAttributes = [], className, attributes = [], itemGroupKey, groupToHeading, groupClassName, groupAttributes = [], separatorClassName, separatorAttributes = [], anchor = {}, } = viewInputs;
435
+ const dispatchSelectedItem = (item, index) => SelectedItem({ index, item });
436
+ const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
437
+ const isVisible = isOpen || isLeaving;
438
+ const animationAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
439
+ h.DataAttribute('closed', ''),
440
+ h.DataAttribute('enter', ''),
441
+ h.DataAttribute('transition', ''),
442
+ ]), M.when('EnterAnimating', () => [
443
+ h.DataAttribute('enter', ''),
444
+ h.DataAttribute('transition', ''),
445
+ ]), M.when('LeaveStart', () => [
446
+ h.DataAttribute('leave', ''),
447
+ h.DataAttribute('transition', ''),
448
+ ]), M.when('LeaveAnimating', () => [
449
+ h.DataAttribute('closed', ''),
450
+ h.DataAttribute('leave', ''),
451
+ h.DataAttribute('transition', ''),
452
+ ]), M.orElse(() => []));
453
+ const isDisabled = (index) => Predicate.isNotUndefined(isItemDisabled) &&
454
+ pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
455
+ const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(0, 1);
456
+ const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(items.length - 1, -1);
457
+ const handleButtonKeyDown = (key) => {
458
+ if (isOpen) {
459
+ return handleItemsKeyDown(key);
460
+ }
461
+ return M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(Opened({ maybeActiveItemIndex: Option.some(firstEnabledIndex) }))), M.when('ArrowUp', () => Option.some(Opened({ maybeActiveItemIndex: Option.some(lastEnabledIndex) }))), M.orElse(() => Option.none()));
462
+ };
463
+ const handleButtonPointerDown = (pointerType, button, screenX, screenY, timeStamp) => Option.some(PressedPointerOnButton({
464
+ pointerType,
465
+ button,
466
+ screenX,
467
+ screenY,
468
+ timeStamp,
469
+ }));
470
+ const handleButtonClick = () => {
471
+ const isMouse = Option.exists(maybeLastButtonPointerType, type => type === 'mouse');
472
+ if (isMouse) {
473
+ return IgnoredMouseClick();
474
+ }
475
+ else if (isOpen) {
476
+ return Closed();
477
+ }
478
+ else {
479
+ return Opened({ maybeActiveItemIndex: Option.none() });
480
+ }
481
+ };
482
+ const handleSpaceKeyUp = (key) => OptionExt.when(key === ' ', SuppressedSpaceScroll());
483
+ const resolveActiveIndex = keyToIndex('ArrowDown', 'ArrowUp', items.length, Option.getOrElse(maybeActiveItemIndex, () => 0), isDisabled);
484
+ const searchForKey = (key) => {
485
+ const nextQuery = searchQuery + key;
486
+ const maybeTargetIndex = resolveTypeaheadMatch(items, nextQuery, maybeActiveItemIndex, isDisabled, itemToSearchText, Str.isNonEmpty(searchQuery));
487
+ return Option.some(Searched({ key, maybeTargetIndex }));
488
+ };
489
+ const handleItemsKeyDown = (key) => M.value(key).pipe(M.when('Escape', () => Option.some(Closed())), M.when('Enter', () => Option.map(maybeActiveItemIndex, index => RequestedItemClick({ index }))), M.when(' ', () => Str.isNonEmpty(searchQuery)
490
+ ? searchForKey(' ')
491
+ : Option.map(maybeActiveItemIndex, index => RequestedItemClick({ index }))), M.whenOr('ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(ActivatedItem({
492
+ index: resolveActiveIndex(key),
493
+ activationTrigger: 'Keyboard',
494
+ }))), M.when(isPrintableKey, () => searchForKey(key)), M.orElse(() => Option.none()));
495
+ const handleItemsPointerUp = (screenX, screenY, pointerType, timeStamp) => OptionExt.when(pointerType === 'mouse', ReleasedPointerOnItems({ screenX, screenY, timeStamp }));
496
+ const resolvedButtonAttributes = [
497
+ h.Id(`${id}-button`),
498
+ h.Type('button'),
499
+ h.AriaHasPopup('menu'),
500
+ h.AriaExpanded(isVisible),
501
+ h.AriaControls(`${id}-items`),
502
+ ...(isButtonDisabled
503
+ ? [h.AriaDisabled(true), h.DataAttribute('disabled', '')]
504
+ : [
505
+ h.OnPointerDown(handleButtonPointerDown),
506
+ h.OnKeyDownPreventDefault(handleButtonKeyDown),
507
+ h.OnKeyUpPreventDefault(handleSpaceKeyUp),
508
+ h.OnClick(handleButtonClick()),
509
+ ]),
510
+ ...(isVisible
511
+ ? [
512
+ h.DataAttribute('open', ''),
513
+ h.Style({ position: 'relative', zIndex: '1' }),
514
+ ]
515
+ : []),
516
+ ...(buttonClassName ? [h.Class(buttonClassName)] : []),
517
+ ...buttonAttributes,
518
+ ];
519
+ const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
520
+ onNone: () => [],
521
+ onSome: index => [h.AriaActiveDescendant(itemId(id, index))],
522
+ });
523
+ const anchorAttributes = [
524
+ h.Style({ position: 'absolute', margin: '0', visibility: 'hidden' }),
525
+ h.OnMount(AnchorMenu({ buttonId: `${id}-button`, anchor })),
526
+ ];
527
+ const itemsContainerAttributes = [
528
+ h.Id(`${id}-items`),
529
+ h.Role('menu'),
530
+ h.AriaLabelledBy(`${id}-button`),
531
+ ...maybeActiveDescendant,
532
+ h.Tabindex(0),
533
+ ...anchorAttributes,
534
+ ...animationAttributes,
535
+ ...(isLeaving
536
+ ? []
537
+ : [
538
+ h.OnKeyDownPreventDefault(handleItemsKeyDown),
539
+ h.OnKeyUpPreventDefault(handleSpaceKeyUp),
540
+ h.OnPointerUp(handleItemsPointerUp),
541
+ h.OnBlur(BlurredItems()),
542
+ ]),
543
+ ...(itemsClassName ? [h.Class(itemsClassName)] : []),
544
+ ...itemsAttributes,
545
+ ];
546
+ const menuItems = Array.map(items, (item, index) => {
547
+ const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
548
+ const isDisabledItem = isDisabled(index);
549
+ const itemConfig = itemToConfig(item, {
550
+ isActive: isActiveItem,
551
+ isDisabled: isDisabledItem,
552
+ });
553
+ const isInteractive = !isDisabledItem && !isLeaving;
554
+ return h.keyed('div')(itemId(id, index), [
555
+ h.Id(itemId(id, index)),
556
+ h.Role('menuitem'),
557
+ ...(isActiveItem ? [h.DataAttribute('active', '')] : []),
558
+ ...(isDisabledItem
559
+ ? [h.AriaDisabled(true), h.DataAttribute('disabled', '')]
560
+ : []),
561
+ ...(isInteractive
562
+ ? [
563
+ h.OnClick(dispatchSelectedItem(item, index)),
564
+ ...(isActiveItem
565
+ ? []
566
+ : [
567
+ h.OnPointerMove((screenX, screenY, pointerType) => OptionExt.when(pointerType !== 'touch', MovedPointerOverItem({ index, screenX, screenY }))),
568
+ ]),
569
+ h.OnPointerLeave(pointerType => OptionExt.when(pointerType !== 'touch', DeactivatedItem())),
570
+ ]
571
+ : []),
572
+ ...(itemConfig.className ? [h.Class(itemConfig.className)] : []),
573
+ ], [itemConfig.content]);
574
+ });
575
+ const renderGroupedItems = () => {
576
+ if (!itemGroupKey) {
577
+ return menuItems;
578
+ }
579
+ const segments = groupContiguous(menuItems, (_, index) => Array.get(items, index).pipe(Option.match({
580
+ onNone: () => '',
581
+ onSome: item => itemGroupKey(item, index),
582
+ })));
583
+ return Array.flatMap(segments, (segment, segmentIndex) => {
584
+ const maybeHeading = Option.fromNullishOr(groupToHeading && groupToHeading(segment.key));
585
+ const headingId = `${id}-heading-${segment.key}`;
586
+ const headingElement = Option.match(maybeHeading, {
587
+ onNone: () => [],
588
+ onSome: heading => [
589
+ h.keyed('div')(headingId, [
590
+ h.Id(headingId),
591
+ h.Role('presentation'),
592
+ ...(heading.className ? [h.Class(heading.className)] : []),
593
+ ], [heading.content]),
594
+ ],
595
+ });
596
+ const groupContent = [...headingElement, ...segment.items];
597
+ const groupElement = h.keyed('div')(`${id}-group-${segment.key}`, [
598
+ h.Role('group'),
599
+ ...(Option.isSome(maybeHeading)
600
+ ? [h.AriaLabelledBy(headingId)]
601
+ : []),
602
+ ...(groupClassName ? [h.Class(groupClassName)] : []),
603
+ ...groupAttributes,
604
+ ], groupContent);
605
+ const separator = segmentIndex > 0 &&
606
+ (separatorClassName ||
607
+ Array.isReadonlyArrayNonEmpty(separatorAttributes))
608
+ ? [
609
+ h.keyed('div')(`${id}-separator-${segmentIndex}`, [
610
+ h.Role('separator'),
611
+ ...(separatorClassName
612
+ ? [h.Class(separatorClassName)]
613
+ : []),
614
+ ...separatorAttributes,
615
+ ], []),
616
+ ]
617
+ : [];
618
+ return [...separator, groupElement];
619
+ });
620
+ };
621
+ const backdrop = h.keyed('div')(`${id}-backdrop`, [
622
+ h.OnMount(PortalMenuBackdrop()),
623
+ ...(isLeaving ? [] : [h.OnClick(Closed())]),
624
+ ...(backdropClassName ? [h.Class(backdropClassName)] : []),
625
+ ...backdropAttributes,
626
+ ], []);
627
+ const renderedItems = renderGroupedItems();
628
+ const scrollableItems = itemsScrollClassName ||
629
+ Array.isReadonlyArrayNonEmpty(itemsScrollAttributes)
630
+ ? [
631
+ h.div([
632
+ ...(itemsScrollClassName
633
+ ? [h.Class(itemsScrollClassName)]
634
+ : []),
635
+ ...itemsScrollAttributes,
636
+ ], renderedItems),
637
+ ]
638
+ : renderedItems;
639
+ const visibleContent = [
640
+ backdrop,
641
+ h.keyed('div')(`${id}-items-container`, itemsContainerAttributes, scrollableItems),
642
+ ];
643
+ const wrapperAttributes = [
644
+ ...(className ? [h.Class(className)] : []),
645
+ ...attributes,
646
+ ...(isVisible ? [h.DataAttribute('open', '')] : []),
647
+ ];
648
+ return h.div(wrapperAttributes, [
649
+ h.keyed('button')(`${id}-button`, resolvedButtonAttributes, [
650
+ buttonContent,
651
+ ]),
652
+ ...(isVisible ? visibleContent : []),
653
+ ]);
654
+ });
655
+ /** Pairs the menu's `view` and `update` (and programmatic helpers)
656
+ * behind a single Item-typed entry point. Declaring the menu once at
657
+ * module scope ensures the view's `Item` type and the OutMessage's
658
+ * `item` type can't drift:
659
+ *
660
+ * ```ts
661
+ * const ActionMenu = Menu.create<Action>()
662
+ *
663
+ * // In view:
664
+ * h.submodel({ view: ActionMenu.view, ... })
665
+ *
666
+ * // In update:
667
+ * const [next, commands, maybeOutMessage] = ActionMenu.update(model.menu, message)
668
+ * // maybeOutMessage: Option<Menu.OutMessage<Action>>
669
+ * ```
670
+ */
671
+ export const create = () => {
672
+ const cast = (result) =>
673
+ /* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
674
+ result;
675
+ return {
676
+ view: internalView(),
677
+ update: (model, message) => cast(update(model, message)),
678
+ selectItem: (model, item, index) => cast(selectItem(model, item, index)),
679
+ open: model => cast(open(model)),
680
+ close: model => cast(close(model)),
681
+ };
682
+ };