foldkit 0.23.0 → 0.25.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 (75) hide show
  1. package/dist/fieldValidation/index.d.ts +3 -4
  2. package/dist/fieldValidation/index.d.ts.map +1 -1
  3. package/dist/fieldValidation/index.js +11 -15
  4. package/dist/html/index.d.ts +42 -0
  5. package/dist/html/index.d.ts.map +1 -1
  6. package/dist/html/index.js +15 -3
  7. package/dist/html/lazy.d.ts +12 -0
  8. package/dist/html/lazy.d.ts.map +1 -0
  9. package/dist/html/lazy.js +35 -0
  10. package/dist/html/public.d.ts +1 -0
  11. package/dist/html/public.d.ts.map +1 -1
  12. package/dist/html/public.js +1 -0
  13. package/dist/index.d.ts +2 -0
  14. package/dist/index.d.ts.map +1 -1
  15. package/dist/index.js +2 -0
  16. package/dist/managedResource/index.d.ts +38 -0
  17. package/dist/managedResource/index.d.ts.map +1 -0
  18. package/dist/managedResource/index.js +20 -0
  19. package/dist/managedResource/public.d.ts +5 -0
  20. package/dist/managedResource/public.d.ts.map +1 -0
  21. package/dist/managedResource/public.js +2 -0
  22. package/dist/runtime/managedResource.d.ts +114 -0
  23. package/dist/runtime/managedResource.d.ts.map +1 -0
  24. package/dist/runtime/managedResource.js +92 -0
  25. package/dist/runtime/public.d.ts +2 -2
  26. package/dist/runtime/public.d.ts.map +1 -1
  27. package/dist/runtime/public.js +1 -1
  28. package/dist/runtime/runtime.d.ts +79 -90
  29. package/dist/runtime/runtime.d.ts.map +1 -1
  30. package/dist/runtime/runtime.js +95 -19
  31. package/dist/runtime/subscription.d.ts +25 -0
  32. package/dist/runtime/subscription.d.ts.map +1 -0
  33. package/dist/runtime/subscription.js +7 -0
  34. package/dist/struct/index.d.ts +2 -0
  35. package/dist/struct/index.d.ts.map +1 -1
  36. package/dist/struct/index.js +4 -0
  37. package/dist/struct/public.d.ts +1 -1
  38. package/dist/struct/public.d.ts.map +1 -1
  39. package/dist/struct/public.js +1 -1
  40. package/dist/subscription/public.d.ts +3 -0
  41. package/dist/subscription/public.d.ts.map +1 -0
  42. package/dist/subscription/public.js +1 -0
  43. package/dist/ui/disclosure/index.d.ts.map +1 -1
  44. package/dist/ui/disclosure/index.js +3 -2
  45. package/dist/ui/group.d.ts +8 -0
  46. package/dist/ui/group.d.ts.map +1 -0
  47. package/dist/ui/group.js +13 -0
  48. package/dist/ui/index.d.ts +1 -0
  49. package/dist/ui/index.d.ts.map +1 -1
  50. package/dist/ui/index.js +1 -0
  51. package/dist/ui/keyboard.d.ts +2 -0
  52. package/dist/ui/keyboard.d.ts.map +1 -1
  53. package/dist/ui/keyboard.js +2 -0
  54. package/dist/ui/listbox/multi.d.ts +172 -0
  55. package/dist/ui/listbox/multi.d.ts.map +1 -0
  56. package/dist/ui/listbox/multi.js +25 -0
  57. package/dist/ui/listbox/multiPublic.d.ts +3 -0
  58. package/dist/ui/listbox/multiPublic.d.ts.map +1 -0
  59. package/dist/ui/listbox/multiPublic.js +1 -0
  60. package/dist/ui/listbox/public.d.ts +7 -0
  61. package/dist/ui/listbox/public.d.ts.map +1 -0
  62. package/dist/ui/listbox/public.js +3 -0
  63. package/dist/ui/listbox/shared.d.ts +236 -0
  64. package/dist/ui/listbox/shared.d.ts.map +1 -0
  65. package/dist/ui/listbox/shared.js +519 -0
  66. package/dist/ui/listbox/single.d.ts +172 -0
  67. package/dist/ui/listbox/single.d.ts.map +1 -0
  68. package/dist/ui/listbox/single.js +29 -0
  69. package/dist/ui/menu/index.d.ts +4 -9
  70. package/dist/ui/menu/index.d.ts.map +1 -1
  71. package/dist/ui/menu/index.js +9 -29
  72. package/dist/ui/typeahead.d.ts +4 -0
  73. package/dist/ui/typeahead.d.ts.map +1 -0
  74. package/dist/ui/typeahead.js +14 -0
  75. package/package.json +13 -1
@@ -0,0 +1,519 @@
1
+ import { Array, Effect, Match as M, Option, Predicate, Schema as S, String as Str, pipe, } from 'effect';
2
+ import { OptionExt } from '../../effectExtensions';
3
+ import { html } from '../../html';
4
+ import { m } from '../../message';
5
+ import { makeConstrainedEvo } from '../../struct';
6
+ import * as Task from '../../task';
7
+ import { groupContiguous } from '../group';
8
+ import { findFirstEnabledIndex, isPrintableKey, keyToIndex } from '../keyboard';
9
+ import { anchorHooks } from '../menu/anchor';
10
+ import { resolveTypeaheadMatch } from '../typeahead';
11
+ export { resolveTypeaheadMatch };
12
+ // MODEL
13
+ /** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
14
+ export const ActivationTrigger = S.Literal('Pointer', 'Keyboard');
15
+ /** Schema for the transition animation state, tracking enter/leave phases for CSS transition coordination. */
16
+ export const TransitionState = S.Literal('Idle', 'EnterStart', 'EnterAnimating', 'LeaveStart', 'LeaveAnimating');
17
+ /** Schema for the listbox orientation — whether items flow vertically or horizontally. */
18
+ export const Orientation = S.Literal('Vertical', 'Horizontal');
19
+ /** Schema fields shared by all listbox variants (single-select and multi-select). Spread into each variant's `S.Struct` to avoid duplicating field definitions. */
20
+ export const BaseModel = S.Struct({
21
+ id: S.String,
22
+ isOpen: S.Boolean,
23
+ isAnimated: S.Boolean,
24
+ isModal: S.Boolean,
25
+ orientation: Orientation,
26
+ transitionState: TransitionState,
27
+ maybeActiveItemIndex: S.OptionFromSelf(S.Number),
28
+ activationTrigger: ActivationTrigger,
29
+ searchQuery: S.String,
30
+ searchVersion: S.Number,
31
+ maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
32
+ maybeLastButtonPointerType: S.OptionFromSelf(S.String),
33
+ });
34
+ /** Creates the shared base fields for a listbox model from a config. Each variant spreads this and adds its selection field. */
35
+ export const baseInit = (config) => ({
36
+ id: config.id,
37
+ isOpen: false,
38
+ isAnimated: config.isAnimated ?? false,
39
+ isModal: config.isModal ?? false,
40
+ orientation: config.orientation ?? 'Vertical',
41
+ transitionState: 'Idle',
42
+ maybeActiveItemIndex: Option.none(),
43
+ activationTrigger: 'Keyboard',
44
+ searchQuery: '',
45
+ searchVersion: 0,
46
+ maybeLastPointerPosition: Option.none(),
47
+ maybeLastButtonPointerType: Option.none(),
48
+ });
49
+ // MESSAGE
50
+ /** Sent when the listbox opens via button click or keyboard. Contains an optional initial active item index — None for pointer, Some for keyboard. */
51
+ export const Opened = m('Opened', {
52
+ maybeActiveItemIndex: S.OptionFromSelf(S.Number),
53
+ });
54
+ /** Sent when the listbox closes via Escape key or backdrop click. */
55
+ export const Closed = m('Closed');
56
+ /** Sent when focus leaves the listbox items container via Tab key or blur. */
57
+ export const ClosedByTab = m('ClosedByTab');
58
+ /** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger. */
59
+ export const ActivatedItem = m('ActivatedItem', {
60
+ index: S.Number,
61
+ activationTrigger: ActivationTrigger,
62
+ });
63
+ /** Sent when the mouse leaves an enabled item. */
64
+ export const DeactivatedItem = m('DeactivatedItem');
65
+ /** Sent when an item is selected via Enter, Space, or click. Contains the item's string value. */
66
+ export const SelectedItem = m('SelectedItem', { item: S.String });
67
+ /** Sent when Enter or Space is pressed on the active item, triggering a programmatic click on the DOM element. */
68
+ export const RequestedItemClick = m('RequestedItemClick', {
69
+ index: S.Number,
70
+ });
71
+ /** Sent when a printable character is typed for typeahead search. */
72
+ export const Searched = m('Searched', {
73
+ key: S.String,
74
+ maybeTargetIndex: S.OptionFromSelf(S.Number),
75
+ });
76
+ /** Sent after the search debounce period to clear the accumulated query. */
77
+ export const ClearedSearch = m('ClearedSearch', { version: S.Number });
78
+ /** Sent when the pointer moves over a listbox item, carrying screen coordinates for tracked-pointer comparison. */
79
+ export const MovedPointerOverItem = m('MovedPointerOverItem', {
80
+ index: S.Number,
81
+ screenX: S.Number,
82
+ screenY: S.Number,
83
+ });
84
+ /** Placeholder message used when no action is needed. */
85
+ export const NoOp = m('NoOp');
86
+ /** Sent internally when a double-rAF completes, advancing the transition to its animating phase. */
87
+ export const AdvancedTransitionFrame = m('AdvancedTransitionFrame');
88
+ /** Sent internally when all CSS transitions on the listbox items container have completed. */
89
+ export const EndedTransition = m('EndedTransition');
90
+ /** Sent internally when the listbox button moves in the viewport during a leave transition, cancelling the animation. */
91
+ export const DetectedButtonMovement = m('DetectedButtonMovement');
92
+ /** Sent when the user presses a pointer device on the listbox button. Records pointer type for click handling. */
93
+ export const PressedPointerOnButton = m('PressedPointerOnButton', {
94
+ pointerType: S.String,
95
+ button: S.Number,
96
+ });
97
+ /** Union of all messages the listbox component can produce. */
98
+ export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, DetectedButtonMovement, PressedPointerOnButton);
99
+ // CONSTANTS
100
+ export const SEARCH_DEBOUNCE_MILLISECONDS = 350;
101
+ export const LEFT_MOUSE_BUTTON = 0;
102
+ // SELECTORS
103
+ export const buttonSelector = (id) => `#${id}-button`;
104
+ export const itemsSelector = (id) => `#${id}-items`;
105
+ export const itemSelector = (id, index) => `#${id}-item-${index}`;
106
+ export const itemId = (id, index) => `${id}-item-${index}`;
107
+ // HELPERS
108
+ const constrainedEvo = makeConstrainedEvo();
109
+ export const closedModel = (model) => constrainedEvo(model, {
110
+ isOpen: () => false,
111
+ transitionState: () => model.isAnimated ? 'LeaveStart' : 'Idle',
112
+ maybeActiveItemIndex: () => Option.none(),
113
+ activationTrigger: () => 'Keyboard',
114
+ searchQuery: () => '',
115
+ searchVersion: () => 0,
116
+ maybeLastPointerPosition: () => Option.none(),
117
+ maybeLastButtonPointerType: () => Option.none(),
118
+ });
119
+ export const makeUpdate = (handleSelectedItem) => {
120
+ const withUpdateReturn = M.withReturnType();
121
+ return (model, message) => {
122
+ const maybeNextFrame = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
123
+ const maybeLockScroll = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
124
+ const maybeUnlockScroll = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
125
+ const maybeInertOthers = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
126
+ buttonSelector(model.id),
127
+ itemsSelector(model.id),
128
+ ]).pipe(Effect.as(NoOp())));
129
+ const maybeRestoreInert = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
130
+ const focusButton = Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp()));
131
+ return M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
132
+ Opened: ({ maybeActiveItemIndex }) => {
133
+ const nextModel = constrainedEvo(model, {
134
+ isOpen: () => true,
135
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
136
+ maybeActiveItemIndex: () => maybeActiveItemIndex,
137
+ activationTrigger: () => Option.match(maybeActiveItemIndex, {
138
+ onNone: () => 'Pointer',
139
+ onSome: () => 'Keyboard',
140
+ }),
141
+ searchQuery: () => '',
142
+ searchVersion: () => 0,
143
+ maybeLastPointerPosition: () => Option.none(),
144
+ });
145
+ return [
146
+ nextModel,
147
+ pipe(Array.getSomes([
148
+ maybeNextFrame,
149
+ maybeLockScroll,
150
+ maybeInertOthers,
151
+ ]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
152
+ ];
153
+ },
154
+ Closed: () => [
155
+ closedModel(model),
156
+ pipe(Array.getSomes([
157
+ maybeNextFrame,
158
+ maybeUnlockScroll,
159
+ maybeRestoreInert,
160
+ ]), Array.prepend(focusButton)),
161
+ ],
162
+ ClosedByTab: () => [
163
+ closedModel(model),
164
+ Array.getSomes([
165
+ maybeNextFrame,
166
+ maybeUnlockScroll,
167
+ maybeRestoreInert,
168
+ ]),
169
+ ],
170
+ ActivatedItem: ({ index, activationTrigger }) => [
171
+ constrainedEvo(model, {
172
+ maybeActiveItemIndex: () => Option.some(index),
173
+ activationTrigger: () => activationTrigger,
174
+ }),
175
+ activationTrigger === 'Keyboard'
176
+ ? [
177
+ Task.scrollIntoView(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
178
+ ]
179
+ : [],
180
+ ],
181
+ MovedPointerOverItem: ({ index, screenX, screenY }) => {
182
+ const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
183
+ if (isSamePosition) {
184
+ return [model, []];
185
+ }
186
+ return [
187
+ constrainedEvo(model, {
188
+ maybeActiveItemIndex: () => Option.some(index),
189
+ activationTrigger: () => 'Pointer',
190
+ maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
191
+ }),
192
+ [],
193
+ ];
194
+ },
195
+ DeactivatedItem: () => model.activationTrigger === 'Pointer'
196
+ ? [
197
+ constrainedEvo(model, {
198
+ maybeActiveItemIndex: () => Option.none(),
199
+ }),
200
+ [],
201
+ ]
202
+ : [model, []],
203
+ SelectedItem: ({ item }) => handleSelectedItem(model, item, {
204
+ focusButton,
205
+ maybeNextFrame,
206
+ maybeUnlockScroll,
207
+ maybeRestoreInert,
208
+ }),
209
+ RequestedItemClick: ({ index }) => [
210
+ model,
211
+ [
212
+ Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
213
+ ],
214
+ ],
215
+ Searched: ({ key, maybeTargetIndex }) => {
216
+ const nextSearchQuery = model.searchQuery + key;
217
+ const nextSearchVersion = model.searchVersion + 1;
218
+ return [
219
+ constrainedEvo(model, {
220
+ searchQuery: () => nextSearchQuery,
221
+ searchVersion: () => nextSearchVersion,
222
+ maybeActiveItemIndex: () => Option.orElse(maybeTargetIndex, () => model.maybeActiveItemIndex),
223
+ }),
224
+ [
225
+ Task.delay(SEARCH_DEBOUNCE_MILLISECONDS).pipe(Effect.as(ClearedSearch({ version: nextSearchVersion }))),
226
+ ],
227
+ ];
228
+ },
229
+ ClearedSearch: ({ version }) => {
230
+ if (version !== model.searchVersion) {
231
+ return [model, []];
232
+ }
233
+ return [constrainedEvo(model, { searchQuery: () => '' }), []];
234
+ },
235
+ AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('EnterStart', () => [
236
+ constrainedEvo(model, {
237
+ transitionState: () => 'EnterAnimating',
238
+ }),
239
+ [
240
+ Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
241
+ ],
242
+ ]), M.when('LeaveStart', () => [
243
+ constrainedEvo(model, {
244
+ transitionState: () => 'LeaveAnimating',
245
+ }),
246
+ [
247
+ Effect.raceFirst(Task.detectElementMovement(buttonSelector(model.id)).pipe(Effect.as(DetectedButtonMovement())), Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition()))),
248
+ ],
249
+ ]), M.orElse(() => [model, []])),
250
+ EndedTransition: () => M.value(model.transitionState).pipe(withUpdateReturn, M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
251
+ constrainedEvo(model, { transitionState: () => 'Idle' }),
252
+ [],
253
+ ]), M.orElse(() => [model, []])),
254
+ DetectedButtonMovement: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('LeaveAnimating', () => [
255
+ constrainedEvo(model, { transitionState: () => 'Idle' }),
256
+ [],
257
+ ]), M.orElse(() => [model, []])),
258
+ PressedPointerOnButton: ({ pointerType, button }) => {
259
+ const withPointerType = constrainedEvo(model, {
260
+ maybeLastButtonPointerType: () => Option.some(pointerType),
261
+ });
262
+ if (pointerType !== 'mouse' || button !== LEFT_MOUSE_BUTTON) {
263
+ return [withPointerType, []];
264
+ }
265
+ if (model.isOpen) {
266
+ return [
267
+ closedModel(withPointerType),
268
+ pipe(Array.getSomes([
269
+ maybeNextFrame,
270
+ maybeUnlockScroll,
271
+ maybeRestoreInert,
272
+ ]), Array.prepend(focusButton)),
273
+ ];
274
+ }
275
+ const nextModel = constrainedEvo(withPointerType, {
276
+ isOpen: () => true,
277
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
278
+ maybeActiveItemIndex: () => Option.none(),
279
+ activationTrigger: () => 'Pointer',
280
+ searchQuery: () => '',
281
+ searchVersion: () => 0,
282
+ maybeLastPointerPosition: () => Option.none(),
283
+ });
284
+ return [
285
+ nextModel,
286
+ pipe(Array.getSomes([
287
+ maybeNextFrame,
288
+ maybeLockScroll,
289
+ maybeInertOthers,
290
+ ]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
291
+ ];
292
+ },
293
+ NoOp: () => [model, []],
294
+ }));
295
+ };
296
+ };
297
+ export const makeView = (behavior) => (config) => {
298
+ const { div, input, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, AriaMultiSelectable, AriaOrientation, AriaSelected, Attribute, Class, DataAttribute, Id, Name, OnBlur, OnClick, OnDestroy, OnInsert, OnKeyDownPreventDefault, OnKeyUpPreventDefault, OnPointerDown, OnPointerLeave, OnPointerMove, Role, Style, Tabindex, Type, Value, keyed, } = html();
299
+ const { model: { id, isOpen, orientation, transitionState, maybeActiveItemIndex, searchQuery, maybeLastButtonPointerType, }, toMessage, items, itemToConfig, isItemDisabled, isButtonDisabled, buttonContent, buttonClassName, itemsClassName, backdropClassName, className, itemGroupKey, groupToHeading, groupClassName, separatorClassName, anchor, name, form, isDisabled, isInvalid, } = config;
300
+ const itemToValue = config.itemToValue ?? (item => String(item));
301
+ const itemToSearchText = config.itemToSearchText ?? (item => itemToValue(item));
302
+ const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
303
+ const isVisible = isOpen || isLeaving;
304
+ const transitionAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
305
+ DataAttribute('closed', ''),
306
+ DataAttribute('enter', ''),
307
+ DataAttribute('transition', ''),
308
+ ]), M.when('EnterAnimating', () => [
309
+ DataAttribute('enter', ''),
310
+ DataAttribute('transition', ''),
311
+ ]), M.when('LeaveStart', () => [
312
+ DataAttribute('leave', ''),
313
+ DataAttribute('transition', ''),
314
+ ]), M.when('LeaveAnimating', () => [
315
+ DataAttribute('closed', ''),
316
+ DataAttribute('leave', ''),
317
+ DataAttribute('transition', ''),
318
+ ]), M.orElse(() => []));
319
+ const isItemDisabledByIndex = (index) => Predicate.isNotUndefined(isItemDisabled) &&
320
+ pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
321
+ const isButtonEffectivelyDisabled = isDisabled || isButtonDisabled;
322
+ const nextKey = orientation === 'Horizontal' ? 'ArrowRight' : 'ArrowDown';
323
+ const previousKey = orientation === 'Horizontal' ? 'ArrowLeft' : 'ArrowUp';
324
+ const navigationKeys = [
325
+ nextKey,
326
+ previousKey,
327
+ 'Home',
328
+ 'End',
329
+ 'PageUp',
330
+ 'PageDown',
331
+ ];
332
+ const isNavigationKey = (key) => Array.contains(navigationKeys, key);
333
+ const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(0, 1);
334
+ const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(items.length - 1, -1);
335
+ const selectedItemIndex = behavior.selectedItemIndex(config.model, items, itemToValue);
336
+ const handleButtonKeyDown = (key) => M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(toMessage(Opened({
337
+ maybeActiveItemIndex: Option.orElse(selectedItemIndex, () => Option.some(firstEnabledIndex)),
338
+ })))), M.when('ArrowUp', () => Option.some(toMessage(Opened({
339
+ maybeActiveItemIndex: Option.orElse(selectedItemIndex, () => Option.some(lastEnabledIndex)),
340
+ })))), M.orElse(() => Option.none()));
341
+ const handleButtonPointerDown = (pointerType, button) => Option.some(toMessage(PressedPointerOnButton({
342
+ pointerType,
343
+ button,
344
+ })));
345
+ const handleButtonClick = () => {
346
+ const isMouse = Option.exists(maybeLastButtonPointerType, type => type === 'mouse');
347
+ if (isMouse) {
348
+ return toMessage(NoOp());
349
+ }
350
+ else if (isOpen) {
351
+ return toMessage(Closed());
352
+ }
353
+ else {
354
+ return toMessage(Opened({ maybeActiveItemIndex: Option.none() }));
355
+ }
356
+ };
357
+ const handleSpaceKeyUp = (key) => OptionExt.when(key === ' ', toMessage(NoOp()));
358
+ const resolveActiveIndex = (key) => Option.match(maybeActiveItemIndex, {
359
+ onNone: () => M.value(key).pipe(M.whenOr(previousKey, 'End', 'PageDown', () => lastEnabledIndex), M.orElse(() => firstEnabledIndex)),
360
+ onSome: activeIndex => keyToIndex(nextKey, previousKey, items.length, activeIndex, isItemDisabledByIndex)(key),
361
+ });
362
+ const searchForKey = (key) => {
363
+ const nextQuery = searchQuery + key;
364
+ const maybeTargetIndex = resolveTypeaheadMatch(items, nextQuery, maybeActiveItemIndex, isItemDisabledByIndex, itemToSearchText, Str.isNonEmpty(searchQuery));
365
+ return Option.some(toMessage(Searched({ key, maybeTargetIndex })));
366
+ };
367
+ const handleItemsKeyDown = (key) => M.value(key).pipe(M.when('Escape', () => Option.some(toMessage(Closed()))), M.when('Enter', () => Option.map(maybeActiveItemIndex, index => toMessage(RequestedItemClick({ index })))), M.when(' ', () => Str.isNonEmpty(searchQuery)
368
+ ? searchForKey(' ')
369
+ : Option.map(maybeActiveItemIndex, index => toMessage(RequestedItemClick({ index })))), M.when(isNavigationKey, () => Option.some(toMessage(ActivatedItem({
370
+ index: resolveActiveIndex(key),
371
+ activationTrigger: 'Keyboard',
372
+ })))), M.when(isPrintableKey, () => searchForKey(key)), M.orElse(() => Option.none()));
373
+ const buttonAttributes = [
374
+ Id(`${id}-button`),
375
+ Type('button'),
376
+ Class(buttonClassName),
377
+ AriaHasPopup('listbox'),
378
+ AriaExpanded(isVisible),
379
+ AriaControls(`${id}-items`),
380
+ ...(isButtonEffectivelyDisabled
381
+ ? [AriaDisabled(true), DataAttribute('disabled', '')]
382
+ : [
383
+ OnPointerDown(handleButtonPointerDown),
384
+ OnKeyDownPreventDefault(handleButtonKeyDown),
385
+ OnKeyUpPreventDefault(handleSpaceKeyUp),
386
+ OnClick(handleButtonClick()),
387
+ ]),
388
+ ...(isVisible ? [DataAttribute('open', '')] : []),
389
+ ...(isInvalid ? [DataAttribute('invalid', '')] : []),
390
+ ];
391
+ const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
392
+ onNone: () => [],
393
+ onSome: index => [AriaActiveDescendant(itemId(id, index))],
394
+ });
395
+ const hooks = anchor
396
+ ? anchorHooks({ buttonId: `${id}-button`, anchor })
397
+ : undefined;
398
+ const anchorAttributes = hooks
399
+ ? [
400
+ Style({ position: 'absolute', margin: '0', visibility: 'hidden' }),
401
+ OnInsert(hooks.onInsert),
402
+ OnDestroy(hooks.onDestroy),
403
+ ]
404
+ : [];
405
+ const itemsContainerAttributes = [
406
+ Id(`${id}-items`),
407
+ Role('listbox'),
408
+ AriaOrientation(Str.toLowerCase(orientation)),
409
+ ...(behavior.ariaMultiSelectable ? [AriaMultiSelectable(true)] : []),
410
+ AriaLabelledBy(`${id}-button`),
411
+ ...maybeActiveDescendant,
412
+ Tabindex(0),
413
+ Class(itemsClassName),
414
+ ...anchorAttributes,
415
+ ...transitionAttributes,
416
+ ...(isLeaving
417
+ ? []
418
+ : [
419
+ OnKeyDownPreventDefault(handleItemsKeyDown),
420
+ OnKeyUpPreventDefault(handleSpaceKeyUp),
421
+ OnBlur(toMessage(ClosedByTab())),
422
+ ]),
423
+ ];
424
+ const listboxItems = Array.map(items, (item, index) => {
425
+ const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
426
+ const isDisabledItem = isItemDisabledByIndex(index);
427
+ const isSelectedItem = behavior.isItemSelected(config.model, itemToValue(item));
428
+ const itemConfig = itemToConfig(item, {
429
+ isActive: isActiveItem,
430
+ isDisabled: isDisabledItem,
431
+ isSelected: isSelectedItem,
432
+ });
433
+ const isInteractive = !isDisabledItem && !isLeaving;
434
+ return keyed('div')(itemId(id, index), [
435
+ Id(itemId(id, index)),
436
+ Role('option'),
437
+ AriaSelected(isSelectedItem),
438
+ Class(itemConfig.className),
439
+ ...(isActiveItem ? [DataAttribute('active', '')] : []),
440
+ ...(isSelectedItem ? [DataAttribute('selected', '')] : []),
441
+ ...(isDisabledItem
442
+ ? [AriaDisabled(true), DataAttribute('disabled', '')]
443
+ : []),
444
+ ...(isInteractive
445
+ ? [
446
+ OnClick(toMessage(SelectedItem({ item: itemToValue(item) }))),
447
+ OnPointerMove((screenX, screenY, pointerType) => OptionExt.when(pointerType !== 'touch', toMessage(MovedPointerOverItem({ index, screenX, screenY })))),
448
+ OnPointerLeave(pointerType => OptionExt.when(pointerType !== 'touch', toMessage(DeactivatedItem()))),
449
+ ]
450
+ : []),
451
+ ], [itemConfig.content]);
452
+ });
453
+ const renderGroupedItems = () => {
454
+ if (!itemGroupKey) {
455
+ return listboxItems;
456
+ }
457
+ const segments = groupContiguous(listboxItems, (_, index) => Array.get(items, index).pipe(Option.match({
458
+ onNone: () => '',
459
+ onSome: item => itemGroupKey(item, index),
460
+ })));
461
+ return Array.flatMap(segments, (segment, segmentIndex) => {
462
+ const maybeHeading = Option.fromNullable(groupToHeading?.(segment.key));
463
+ const headingId = `${id}-heading-${segment.key}`;
464
+ const headingElement = Option.match(maybeHeading, {
465
+ onNone: () => [],
466
+ onSome: heading => [
467
+ keyed('div')(headingId, [Id(headingId), Role('presentation'), Class(heading.className)], [heading.content]),
468
+ ],
469
+ });
470
+ const groupContent = [...headingElement, ...segment.items];
471
+ const groupElement = keyed('div')(`${id}-group-${segment.key}`, [
472
+ Role('group'),
473
+ ...(Option.isSome(maybeHeading) ? [AriaLabelledBy(headingId)] : []),
474
+ ...(groupClassName ? [Class(groupClassName)] : []),
475
+ ], groupContent);
476
+ const separator = segmentIndex > 0 && separatorClassName
477
+ ? [
478
+ keyed('div')(`${id}-separator-${segmentIndex}`, [Role('separator'), Class(separatorClassName)], []),
479
+ ]
480
+ : [];
481
+ return [...separator, groupElement];
482
+ });
483
+ };
484
+ const backdrop = keyed('div')(`${id}-backdrop`, [
485
+ Class(backdropClassName),
486
+ ...(isLeaving ? [] : [OnClick(toMessage(Closed()))]),
487
+ ], []);
488
+ const renderedItems = renderGroupedItems();
489
+ const visibleContent = [
490
+ backdrop,
491
+ keyed('div')(`${id}-items-container`, itemsContainerAttributes, renderedItems),
492
+ ];
493
+ const formAttribute = form ? [Attribute('form', form)] : [];
494
+ const selectedValues = pipe(items, Array.filter(item => behavior.isItemSelected(config.model, itemToValue(item))), Array.map(itemToValue));
495
+ const hiddenInputs = name
496
+ ? Array.match(selectedValues, {
497
+ onEmpty: () => [
498
+ input([Type('hidden'), Name(name), ...formAttribute]),
499
+ ],
500
+ onNonEmpty: Array.map(selectedValue => input([
501
+ Type('hidden'),
502
+ Name(name),
503
+ Value(selectedValue),
504
+ ...formAttribute,
505
+ ])),
506
+ })
507
+ : [];
508
+ const wrapperAttributes = [
509
+ ...(className ? [Class(className)] : []),
510
+ ...(isVisible ? [DataAttribute('open', '')] : []),
511
+ ...(isDisabled ? [DataAttribute('disabled', '')] : []),
512
+ ...(isInvalid ? [DataAttribute('invalid', '')] : []),
513
+ ];
514
+ return div(wrapperAttributes, [
515
+ keyed('button')(`${id}-button`, buttonAttributes, [buttonContent]),
516
+ ...hiddenInputs,
517
+ ...(isVisible ? visibleContent : []),
518
+ ]);
519
+ };