foldkit 0.26.0 → 0.27.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 (70) hide show
  1. package/dist/ui/combobox/multi.d.ts +178 -0
  2. package/dist/ui/combobox/multi.d.ts.map +1 -0
  3. package/dist/ui/combobox/multi.js +53 -0
  4. package/dist/ui/combobox/multiPublic.d.ts +3 -0
  5. package/dist/ui/combobox/multiPublic.d.ts.map +1 -0
  6. package/dist/ui/combobox/multiPublic.js +1 -0
  7. package/dist/ui/combobox/public.d.ts +8 -0
  8. package/dist/ui/combobox/public.d.ts.map +1 -0
  9. package/dist/ui/combobox/public.js +4 -0
  10. package/dist/ui/combobox/shared.d.ts +236 -0
  11. package/dist/ui/combobox/shared.d.ts.map +1 -0
  12. package/dist/ui/combobox/shared.js +560 -0
  13. package/dist/ui/combobox/single.d.ts +183 -0
  14. package/dist/ui/combobox/single.d.ts.map +1 -0
  15. package/dist/ui/combobox/single.js +73 -0
  16. package/dist/ui/dialog/index.d.ts +3 -0
  17. package/dist/ui/dialog/index.d.ts.map +1 -1
  18. package/dist/ui/dialog/index.js +11 -0
  19. package/dist/ui/dialog/public.d.ts +1 -1
  20. package/dist/ui/dialog/public.d.ts.map +1 -1
  21. package/dist/ui/dialog/public.js +1 -1
  22. package/dist/ui/disclosure/index.d.ts +3 -0
  23. package/dist/ui/disclosure/index.d.ts.map +1 -1
  24. package/dist/ui/disclosure/index.js +11 -0
  25. package/dist/ui/disclosure/public.d.ts +1 -1
  26. package/dist/ui/disclosure/public.d.ts.map +1 -1
  27. package/dist/ui/disclosure/public.js +1 -1
  28. package/dist/ui/index.d.ts +1 -0
  29. package/dist/ui/index.d.ts.map +1 -1
  30. package/dist/ui/index.js +1 -0
  31. package/dist/ui/listbox/multi.d.ts +26 -21
  32. package/dist/ui/listbox/multi.d.ts.map +1 -1
  33. package/dist/ui/listbox/multi.js +11 -0
  34. package/dist/ui/listbox/multiPublic.d.ts +1 -1
  35. package/dist/ui/listbox/multiPublic.d.ts.map +1 -1
  36. package/dist/ui/listbox/multiPublic.js +1 -1
  37. package/dist/ui/listbox/public.d.ts +1 -1
  38. package/dist/ui/listbox/public.d.ts.map +1 -1
  39. package/dist/ui/listbox/public.js +1 -1
  40. package/dist/ui/listbox/shared.d.ts +9 -8
  41. package/dist/ui/listbox/shared.d.ts.map +1 -1
  42. package/dist/ui/listbox/shared.js +10 -3
  43. package/dist/ui/listbox/single.d.ts +26 -21
  44. package/dist/ui/listbox/single.d.ts.map +1 -1
  45. package/dist/ui/listbox/single.js +11 -0
  46. package/dist/ui/menu/index.d.ts +4 -0
  47. package/dist/ui/menu/index.d.ts.map +1 -1
  48. package/dist/ui/menu/index.js +21 -3
  49. package/dist/ui/menu/public.d.ts +1 -1
  50. package/dist/ui/menu/public.d.ts.map +1 -1
  51. package/dist/ui/menu/public.js +1 -1
  52. package/dist/ui/popover/index.d.ts +3 -0
  53. package/dist/ui/popover/index.d.ts.map +1 -1
  54. package/dist/ui/popover/index.js +11 -0
  55. package/dist/ui/popover/public.d.ts +1 -1
  56. package/dist/ui/popover/public.d.ts.map +1 -1
  57. package/dist/ui/popover/public.js +1 -1
  58. package/dist/ui/switch/index.d.ts +3 -0
  59. package/dist/ui/switch/index.d.ts.map +1 -1
  60. package/dist/ui/switch/index.js +11 -0
  61. package/dist/ui/switch/public.d.ts +1 -1
  62. package/dist/ui/switch/public.d.ts.map +1 -1
  63. package/dist/ui/switch/public.js +1 -1
  64. package/dist/ui/tabs/index.d.ts +3 -0
  65. package/dist/ui/tabs/index.d.ts.map +1 -1
  66. package/dist/ui/tabs/index.js +11 -0
  67. package/dist/ui/tabs/public.d.ts +1 -1
  68. package/dist/ui/tabs/public.d.ts.map +1 -1
  69. package/dist/ui/tabs/public.js +1 -1
  70. package/package.json +6 -2
@@ -0,0 +1,560 @@
1
+ import { Array, Effect, Match as M, Option, Predicate, Schema as S, 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 { anchorHooks } from '../anchor';
8
+ import { groupContiguous } from '../group';
9
+ import { findFirstEnabledIndex, keyToIndex } from '../keyboard';
10
+ import { TransitionState } from '../transition';
11
+ export { groupContiguous };
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 fields shared by all combobox variants (single-select and multi-select). Spread into each variant's `S.Struct` to avoid duplicating field definitions. */
16
+ export const BaseModel = S.Struct({
17
+ id: S.String,
18
+ isOpen: S.Boolean,
19
+ isAnimated: S.Boolean,
20
+ isModal: S.Boolean,
21
+ nullable: S.Boolean,
22
+ immediate: S.Boolean,
23
+ selectInputOnFocus: S.Boolean,
24
+ transitionState: TransitionState,
25
+ maybeActiveItemIndex: S.OptionFromSelf(S.Number),
26
+ activationTrigger: ActivationTrigger,
27
+ inputValue: S.String,
28
+ maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
29
+ });
30
+ /** Creates the shared base fields for a combobox model from a config. Each variant spreads this and adds its selection fields. */
31
+ export const baseInit = (config) => ({
32
+ id: config.id,
33
+ isOpen: false,
34
+ isAnimated: config.isAnimated ?? false,
35
+ isModal: config.isModal ?? false,
36
+ nullable: config.nullable ?? false,
37
+ immediate: config.immediate ?? false,
38
+ selectInputOnFocus: config.selectInputOnFocus ?? false,
39
+ transitionState: 'Idle',
40
+ maybeActiveItemIndex: Option.none(),
41
+ activationTrigger: 'Keyboard',
42
+ inputValue: '',
43
+ maybeLastPointerPosition: Option.none(),
44
+ });
45
+ // MESSAGE
46
+ /** Sent when the combobox popup opens. Contains an optional initial active item index. */
47
+ export const Opened = m('Opened', {
48
+ maybeActiveItemIndex: S.OptionFromSelf(S.Number),
49
+ });
50
+ /** Sent when the combobox closes via Escape key or backdrop click. */
51
+ export const Closed = m('Closed');
52
+ /** Sent when focus leaves the input via Tab key or blur. */
53
+ export const ClosedByTab = m('ClosedByTab');
54
+ /** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger and optional immediate selection info. */
55
+ export const ActivatedItem = m('ActivatedItem', {
56
+ index: S.Number,
57
+ activationTrigger: ActivationTrigger,
58
+ maybeImmediateSelection: S.OptionFromSelf(S.Struct({ item: S.String, displayText: S.String })),
59
+ });
60
+ /** Sent when the mouse leaves an enabled item. */
61
+ export const DeactivatedItem = m('DeactivatedItem');
62
+ /** Sent when an item is selected via Enter or click. Includes display text for restoring input value on close. */
63
+ export const SelectedItem = m('SelectedItem', {
64
+ item: S.String,
65
+ displayText: S.String,
66
+ });
67
+ /** Sent when the pointer moves over a combobox item. */
68
+ export const MovedPointerOverItem = m('MovedPointerOverItem', {
69
+ index: S.Number,
70
+ screenX: S.Number,
71
+ screenY: S.Number,
72
+ });
73
+ /** Sent when Enter or Space is pressed on the active item, triggering a programmatic click. */
74
+ export const RequestedItemClick = m('RequestedItemClick', {
75
+ index: S.Number,
76
+ });
77
+ /** Placeholder message used when no action is needed. */
78
+ export const NoOp = m('NoOp');
79
+ /** Sent internally when a double-rAF completes, advancing the transition to its animating phase. */
80
+ export const AdvancedTransitionFrame = m('AdvancedTransitionFrame');
81
+ /** Sent internally when all CSS transitions on the items container have completed. */
82
+ export const EndedTransition = m('EndedTransition');
83
+ /** Sent internally when the input wrapper moves in the viewport during a leave transition, cancelling the animation. */
84
+ export const DetectedInputMovement = m('DetectedInputMovement');
85
+ /** Sent when the user types in the input. */
86
+ export const UpdatedInputValue = m('UpdatedInputValue', {
87
+ value: S.String,
88
+ });
89
+ /** Sent when the optional toggle button is clicked. */
90
+ export const PressedToggleButton = m('PressedToggleButton');
91
+ /** Union of all messages the combobox component can produce. */
92
+ export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, NoOp, AdvancedTransitionFrame, EndedTransition, DetectedInputMovement, UpdatedInputValue, PressedToggleButton);
93
+ // SELECTORS
94
+ export const inputSelector = (id) => `#${id}-input`;
95
+ export const inputWrapperSelector = (id) => `#${id}-input-wrapper`;
96
+ export const itemsSelector = (id) => `#${id}-items`;
97
+ export const itemSelector = (id, index) => `#${id}-item-${index}`;
98
+ export const itemId = (id, index) => `${id}-item-${index}`;
99
+ // HELPERS
100
+ const constrainedEvo = makeConstrainedEvo();
101
+ /** Resets only shared base fields to their closed state. Does not touch inputValue or selection — those are variant-specific. */
102
+ export const closedBaseModel = (model) => constrainedEvo(model, {
103
+ isOpen: () => false,
104
+ transitionState: () => model.isAnimated ? 'LeaveStart' : 'Idle',
105
+ maybeActiveItemIndex: () => Option.none(),
106
+ activationTrigger: () => 'Keyboard',
107
+ maybeLastPointerPosition: () => Option.none(),
108
+ });
109
+ /** Creates a combobox update function from variant-specific handlers. Shared logic (open, close, activate, transition) is handled internally; only close, selection, and immediate-activation behavior varies by variant. */
110
+ export const makeUpdate = (handlers) => {
111
+ const withUpdateReturn = M.withReturnType();
112
+ return (model, message) => {
113
+ const maybeNextFrame = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
114
+ const maybeLockScroll = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
115
+ const maybeUnlockScroll = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
116
+ const maybeInertOthers = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
117
+ inputWrapperSelector(model.id),
118
+ itemsSelector(model.id),
119
+ ]).pipe(Effect.as(NoOp())));
120
+ const maybeRestoreInert = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
121
+ const focusInput = Task.focus(inputSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp()));
122
+ return M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
123
+ Opened: ({ maybeActiveItemIndex }) => {
124
+ const nextModel = constrainedEvo(model, {
125
+ isOpen: () => true,
126
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
127
+ maybeActiveItemIndex: () => maybeActiveItemIndex,
128
+ activationTrigger: () => Option.match(maybeActiveItemIndex, {
129
+ onNone: () => 'Pointer',
130
+ onSome: () => 'Keyboard',
131
+ }),
132
+ maybeLastPointerPosition: () => Option.none(),
133
+ });
134
+ return [
135
+ nextModel,
136
+ Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]),
137
+ ];
138
+ },
139
+ Closed: () => [
140
+ handlers.handleClose(model),
141
+ pipe(Array.getSomes([
142
+ maybeNextFrame,
143
+ maybeUnlockScroll,
144
+ maybeRestoreInert,
145
+ ]), Array.prepend(focusInput)),
146
+ ],
147
+ ClosedByTab: () => [
148
+ handlers.handleClose(model),
149
+ Array.getSomes([
150
+ maybeNextFrame,
151
+ maybeUnlockScroll,
152
+ maybeRestoreInert,
153
+ ]),
154
+ ],
155
+ ActivatedItem: ({ index, activationTrigger, maybeImmediateSelection, }) => {
156
+ const highlightedModel = constrainedEvo(model, {
157
+ maybeActiveItemIndex: () => Option.some(index),
158
+ activationTrigger: () => activationTrigger,
159
+ });
160
+ const nextModel = Option.match(maybeImmediateSelection, {
161
+ onNone: () => highlightedModel,
162
+ onSome: ({ item, displayText }) => handlers.handleImmediateActivation(highlightedModel, item, displayText),
163
+ });
164
+ return [
165
+ nextModel,
166
+ activationTrigger === 'Keyboard'
167
+ ? [
168
+ Task.scrollIntoView(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
169
+ ]
170
+ : [],
171
+ ];
172
+ },
173
+ MovedPointerOverItem: ({ index, screenX, screenY }) => {
174
+ const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
175
+ if (isSamePosition) {
176
+ return [model, []];
177
+ }
178
+ return [
179
+ constrainedEvo(model, {
180
+ maybeActiveItemIndex: () => Option.some(index),
181
+ activationTrigger: () => 'Pointer',
182
+ maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
183
+ }),
184
+ [],
185
+ ];
186
+ },
187
+ DeactivatedItem: () => model.activationTrigger === 'Pointer'
188
+ ? [
189
+ constrainedEvo(model, {
190
+ maybeActiveItemIndex: () => Option.none(),
191
+ }),
192
+ [],
193
+ ]
194
+ : [model, []],
195
+ SelectedItem: ({ item, displayText }) => handlers.handleSelectedItem(model, item, displayText, {
196
+ focusInput,
197
+ maybeNextFrame,
198
+ maybeUnlockScroll,
199
+ maybeRestoreInert,
200
+ }),
201
+ RequestedItemClick: ({ index }) => [
202
+ model,
203
+ [
204
+ Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
205
+ ],
206
+ ],
207
+ UpdatedInputValue: ({ value }) => {
208
+ if (model.isOpen) {
209
+ return [
210
+ constrainedEvo(model, {
211
+ inputValue: () => value,
212
+ maybeActiveItemIndex: () => Option.some(0),
213
+ activationTrigger: () => 'Keyboard',
214
+ }),
215
+ [],
216
+ ];
217
+ }
218
+ const nextModel = constrainedEvo(model, {
219
+ isOpen: () => true,
220
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
221
+ inputValue: () => value,
222
+ maybeActiveItemIndex: () => Option.some(0),
223
+ activationTrigger: () => 'Keyboard',
224
+ maybeLastPointerPosition: () => Option.none(),
225
+ });
226
+ return [
227
+ nextModel,
228
+ Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]),
229
+ ];
230
+ },
231
+ PressedToggleButton: () => {
232
+ if (model.isOpen) {
233
+ return [
234
+ handlers.handleClose(model),
235
+ pipe(Array.getSomes([
236
+ maybeNextFrame,
237
+ maybeUnlockScroll,
238
+ maybeRestoreInert,
239
+ ]), Array.prepend(focusInput)),
240
+ ];
241
+ }
242
+ const nextModel = constrainedEvo(model, {
243
+ isOpen: () => true,
244
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
245
+ maybeActiveItemIndex: () => Option.none(),
246
+ activationTrigger: () => 'Pointer',
247
+ maybeLastPointerPosition: () => Option.none(),
248
+ });
249
+ return [
250
+ nextModel,
251
+ pipe(Array.getSomes([
252
+ maybeNextFrame,
253
+ maybeLockScroll,
254
+ maybeInertOthers,
255
+ ]), Array.prepend(focusInput)),
256
+ ];
257
+ },
258
+ AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('EnterStart', () => [
259
+ constrainedEvo(model, {
260
+ transitionState: () => 'EnterAnimating',
261
+ }),
262
+ [
263
+ Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
264
+ ],
265
+ ]), M.when('LeaveStart', () => [
266
+ constrainedEvo(model, {
267
+ transitionState: () => 'LeaveAnimating',
268
+ }),
269
+ [
270
+ Effect.raceFirst(Task.detectElementMovement(inputWrapperSelector(model.id)).pipe(Effect.as(DetectedInputMovement())), Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition()))),
271
+ ],
272
+ ]), M.orElse(() => [model, []])),
273
+ EndedTransition: () => M.value(model.transitionState).pipe(withUpdateReturn, M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
274
+ constrainedEvo(model, {
275
+ transitionState: () => 'Idle',
276
+ }),
277
+ [],
278
+ ]), M.orElse(() => [model, []])),
279
+ DetectedInputMovement: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('LeaveAnimating', () => [
280
+ constrainedEvo(model, {
281
+ transitionState: () => 'Idle',
282
+ }),
283
+ [],
284
+ ]), M.orElse(() => [model, []])),
285
+ NoOp: () => [model, []],
286
+ }));
287
+ };
288
+ };
289
+ /** Creates a combobox view function from variant-specific behavior. Shared rendering logic (input, items, transitions, keyboard navigation) is handled internally; only selection display varies by variant. */
290
+ export const makeView = (behavior) => (config) => {
291
+ const { div, input, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaInvalid, AriaLabelledBy, AriaMultiSelectable, AriaSelected, Attribute, Autocomplete, Class, DataAttribute, Id, Name, OnBlur, OnClick, OnDestroy, OnFocus, OnInput, OnInsert, OnKeyDownPreventDefault, OnPointerLeave, OnPointerMove, Role, Style, Tabindex, Type, Value, keyed, } = html();
292
+ const { model: { id, isOpen, immediate, transitionState, maybeActiveItemIndex }, toMessage, items, itemToConfig, itemToValue, itemToDisplayText, isItemDisabled, inputClassName, inputPlaceholder, itemsClassName, itemsScrollClassName, backdropClassName, className, inputWrapperClassName, buttonContent, buttonClassName, formName, isDisabled, isInvalid, openOnFocus, itemGroupKey, groupToHeading, groupClassName, separatorClassName, anchor, } = config;
293
+ const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
294
+ const isVisible = isOpen || isLeaving;
295
+ const transitionAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
296
+ DataAttribute('closed', ''),
297
+ DataAttribute('enter', ''),
298
+ DataAttribute('transition', ''),
299
+ ]), M.when('EnterAnimating', () => [
300
+ DataAttribute('enter', ''),
301
+ DataAttribute('transition', ''),
302
+ ]), M.when('LeaveStart', () => [
303
+ DataAttribute('leave', ''),
304
+ DataAttribute('transition', ''),
305
+ ]), M.when('LeaveAnimating', () => [
306
+ DataAttribute('closed', ''),
307
+ DataAttribute('leave', ''),
308
+ DataAttribute('transition', ''),
309
+ ]), M.orElse(() => []));
310
+ const isDisabledAtIndex = (index) => Predicate.isNotUndefined(isItemDisabled) &&
311
+ pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
312
+ const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabledAtIndex)(0, 1);
313
+ const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabledAtIndex)(items.length - 1, -1);
314
+ const resolveActiveIndex = keyToIndex('ArrowDown', 'ArrowUp', items.length, Option.getOrElse(maybeActiveItemIndex, () => -1), isDisabledAtIndex);
315
+ const resolveImmediateSelection = (targetIndex) => OptionExt.when(immediate, pipe(items, Array.get(targetIndex), Option.match({
316
+ onNone: () => ({ item: '', displayText: '' }),
317
+ onSome: targetItem => ({
318
+ item: itemToValue(targetItem, targetIndex),
319
+ displayText: itemToDisplayText(targetItem, targetIndex),
320
+ }),
321
+ })));
322
+ const handleInputKeyDown = (key) => M.value(key).pipe(M.when('ArrowDown', () => {
323
+ if (!isOpen) {
324
+ return Option.some(toMessage(Opened({
325
+ maybeActiveItemIndex: Option.some(firstEnabledIndex),
326
+ })));
327
+ }
328
+ const targetIndex = resolveActiveIndex('ArrowDown');
329
+ return Option.some(toMessage(ActivatedItem({
330
+ index: targetIndex,
331
+ activationTrigger: 'Keyboard',
332
+ maybeImmediateSelection: resolveImmediateSelection(targetIndex),
333
+ })));
334
+ }), M.when('ArrowUp', () => {
335
+ if (!isOpen) {
336
+ return Option.some(toMessage(Opened({
337
+ maybeActiveItemIndex: Option.some(lastEnabledIndex),
338
+ })));
339
+ }
340
+ const targetIndex = resolveActiveIndex('ArrowUp');
341
+ return Option.some(toMessage(ActivatedItem({
342
+ index: targetIndex,
343
+ activationTrigger: 'Keyboard',
344
+ maybeImmediateSelection: resolveImmediateSelection(targetIndex),
345
+ })));
346
+ }), M.when('Enter', () => {
347
+ if (!isOpen) {
348
+ return Option.none();
349
+ }
350
+ return Option.map(maybeActiveItemIndex, index => toMessage(RequestedItemClick({ index })));
351
+ }), M.when('Escape', () => {
352
+ if (!isOpen) {
353
+ return Option.none();
354
+ }
355
+ return Option.some(toMessage(Closed()));
356
+ }), M.whenOr('Home', 'End', () => {
357
+ if (!isOpen) {
358
+ return Option.none();
359
+ }
360
+ const targetIndex = resolveActiveIndex(key);
361
+ return Option.some(toMessage(ActivatedItem({
362
+ index: targetIndex,
363
+ activationTrigger: 'Keyboard',
364
+ maybeImmediateSelection: resolveImmediateSelection(targetIndex),
365
+ })));
366
+ }), M.orElse(() => Option.none()));
367
+ const preventBlurOnPointerDown = (element) => {
368
+ element.addEventListener('pointerdown', (event) => {
369
+ event.preventDefault();
370
+ }, { capture: true });
371
+ };
372
+ const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
373
+ onNone: () => [],
374
+ onSome: index => [AriaActiveDescendant(itemId(id, index))],
375
+ });
376
+ const inputAttributes = [
377
+ Id(`${id}-input`),
378
+ Role('combobox'),
379
+ Class(inputClassName),
380
+ AriaExpanded(isVisible),
381
+ AriaControls(`${id}-items`),
382
+ Attribute('aria-autocomplete', 'list'),
383
+ Attribute('aria-haspopup', 'listbox'),
384
+ Autocomplete('off'),
385
+ Value(config.model.inputValue),
386
+ ...maybeActiveDescendant,
387
+ ...(inputPlaceholder ? [Attribute('placeholder', inputPlaceholder)] : []),
388
+ ...(isDisabled
389
+ ? [AriaDisabled(true), DataAttribute('disabled', '')]
390
+ : [
391
+ OnInput(value => toMessage(UpdatedInputValue({ value }))),
392
+ OnKeyDownPreventDefault(handleInputKeyDown),
393
+ OnBlur(toMessage(ClosedByTab())),
394
+ ...(openOnFocus
395
+ ? [
396
+ OnFocus(toMessage(Opened({ maybeActiveItemIndex: Option.none() }))),
397
+ ]
398
+ : []),
399
+ ]),
400
+ ...(isInvalid ? [AriaInvalid(true), DataAttribute('invalid', '')] : []),
401
+ ...(isVisible ? [DataAttribute('open', '')] : []),
402
+ ...(config.model.selectInputOnFocus
403
+ ? [
404
+ OnInsert((element) => {
405
+ element.addEventListener('focus', () => {
406
+ if (element instanceof HTMLInputElement) {
407
+ element.select();
408
+ }
409
+ });
410
+ }),
411
+ ]
412
+ : []),
413
+ ];
414
+ const hooks = anchor
415
+ ? anchorHooks({
416
+ buttonId: `${id}-input-wrapper`,
417
+ anchor,
418
+ interceptTab: false,
419
+ })
420
+ : undefined;
421
+ const anchorAttributes = hooks
422
+ ? [
423
+ Style({ position: 'absolute', margin: '0', visibility: 'hidden' }),
424
+ OnInsert((element) => {
425
+ preventBlurOnPointerDown(element);
426
+ hooks.onInsert(element);
427
+ }),
428
+ OnDestroy(hooks.onDestroy),
429
+ ]
430
+ : [OnInsert(preventBlurOnPointerDown)];
431
+ const itemsContainerAttributes = [
432
+ Id(`${id}-items`),
433
+ Role('listbox'),
434
+ ...(behavior.ariaMultiSelectable ? [AriaMultiSelectable(true)] : []),
435
+ AriaLabelledBy(`${id}-input`),
436
+ Tabindex(-1),
437
+ Class(itemsClassName),
438
+ ...anchorAttributes,
439
+ ...transitionAttributes,
440
+ ];
441
+ const comboboxItems = Array.map(items, (item, index) => {
442
+ const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
443
+ const isDisabledItem = isDisabledAtIndex(index);
444
+ const isSelectedItem = behavior.isItemSelected(config.model, itemToValue(item, index));
445
+ const itemConfig = itemToConfig(item, {
446
+ isActive: isActiveItem,
447
+ isDisabled: isDisabledItem,
448
+ isSelected: isSelectedItem,
449
+ });
450
+ const isInteractive = !isDisabledItem && !isLeaving;
451
+ return keyed('div')(itemId(id, index), [
452
+ Id(itemId(id, index)),
453
+ Role('option'),
454
+ AriaSelected(isSelectedItem),
455
+ Class(itemConfig.className),
456
+ ...(isActiveItem ? [DataAttribute('active', '')] : []),
457
+ ...(isSelectedItem ? [DataAttribute('selected', '')] : []),
458
+ ...(isDisabledItem
459
+ ? [AriaDisabled(true), DataAttribute('disabled', '')]
460
+ : []),
461
+ ...(isInteractive
462
+ ? [
463
+ OnClick(toMessage(SelectedItem({
464
+ item: itemToValue(item, index),
465
+ displayText: itemToDisplayText(item, index),
466
+ }))),
467
+ OnPointerMove((screenX, screenY, pointerType) => OptionExt.when(pointerType !== 'touch', toMessage(MovedPointerOverItem({ index, screenX, screenY })))),
468
+ OnPointerLeave(pointerType => OptionExt.when(pointerType !== 'touch', toMessage(DeactivatedItem()))),
469
+ ]
470
+ : []),
471
+ ], [itemConfig.content]);
472
+ });
473
+ const renderGroupedItems = () => {
474
+ if (!itemGroupKey) {
475
+ return comboboxItems;
476
+ }
477
+ const segments = groupContiguous(comboboxItems, (_, index) => Array.get(items, index).pipe(Option.match({
478
+ onNone: () => '',
479
+ onSome: item => itemGroupKey(item, index),
480
+ })));
481
+ return Array.flatMap(segments, (segment, segmentIndex) => {
482
+ const maybeHeading = Option.fromNullable(groupToHeading && groupToHeading(segment.key));
483
+ const headingId = `${id}-heading-${segment.key}`;
484
+ const headingElement = Option.match(maybeHeading, {
485
+ onNone: () => [],
486
+ onSome: heading => [
487
+ keyed('div')(headingId, [Id(headingId), Role('presentation'), Class(heading.className)], [heading.content]),
488
+ ],
489
+ });
490
+ const groupContent = [...headingElement, ...segment.items];
491
+ const groupElement = keyed('div')(`${id}-group-${segment.key}`, [
492
+ Role('group'),
493
+ ...(Option.isSome(maybeHeading) ? [AriaLabelledBy(headingId)] : []),
494
+ ...(groupClassName ? [Class(groupClassName)] : []),
495
+ ], groupContent);
496
+ const separator = segmentIndex > 0 && separatorClassName
497
+ ? [
498
+ keyed('div')(`${id}-separator-${segmentIndex}`, [Role('separator'), Class(separatorClassName)], []),
499
+ ]
500
+ : [];
501
+ return [...separator, groupElement];
502
+ });
503
+ };
504
+ const backdrop = keyed('div')(`${id}-backdrop`, [
505
+ Class(backdropClassName),
506
+ ...(isLeaving ? [] : [OnClick(toMessage(Closed()))]),
507
+ ], []);
508
+ const renderedItems = renderGroupedItems();
509
+ const scrollableItems = itemsScrollClassName
510
+ ? [div([Class(itemsScrollClassName)], renderedItems)]
511
+ : renderedItems;
512
+ const visibleContent = [
513
+ backdrop,
514
+ keyed('div')(`${id}-items-container`, itemsContainerAttributes, scrollableItems),
515
+ ];
516
+ const inputWrapperAttributes = [
517
+ Id(`${id}-input-wrapper`),
518
+ ...(inputWrapperClassName ? [Class(inputWrapperClassName)] : []),
519
+ ];
520
+ const toggleButton = buttonContent && buttonClassName
521
+ ? [
522
+ keyed('button')(`${id}-button`, [
523
+ Id(`${id}-button`),
524
+ Type('button'),
525
+ Class(buttonClassName),
526
+ Tabindex(-1),
527
+ AriaControls(`${id}-items`),
528
+ AriaExpanded(isVisible),
529
+ Attribute('aria-haspopup', 'listbox'),
530
+ ...(isDisabled
531
+ ? [AriaDisabled(true), DataAttribute('disabled', '')]
532
+ : [OnClick(toMessage(PressedToggleButton()))]),
533
+ OnInsert(preventBlurOnPointerDown),
534
+ ], [buttonContent]),
535
+ ]
536
+ : [];
537
+ const selectedValues = pipe(items, Array.filterMap((item, index) => {
538
+ const value = itemToValue(item, index);
539
+ return OptionExt.when(behavior.isItemSelected(config.model, value), value);
540
+ }));
541
+ const hiddenInputs = formName
542
+ ? Array.match(selectedValues, {
543
+ onEmpty: () => [input([Type('hidden'), Name(formName)])],
544
+ onNonEmpty: Array.map(selectedValue => input([Type('hidden'), Name(formName), Value(selectedValue)])),
545
+ })
546
+ : [];
547
+ const wrapperAttributes = [
548
+ ...(className ? [Class(className)] : []),
549
+ ...(isVisible ? [DataAttribute('open', '')] : []),
550
+ ...(isDisabled ? [DataAttribute('disabled', '')] : []),
551
+ ...(isInvalid ? [DataAttribute('invalid', '')] : []),
552
+ ];
553
+ return div(wrapperAttributes, [
554
+ div(inputWrapperAttributes, [input(inputAttributes), ...toggleButton]),
555
+ ...(isVisible && Array.isNonEmptyReadonlyArray(items)
556
+ ? visibleContent
557
+ : []),
558
+ ...hiddenInputs,
559
+ ]);
560
+ };