foldkit 0.24.0 → 0.26.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 (94) hide show
  1. package/README.md +70 -55
  2. package/dist/fieldValidation/index.d.ts +39 -30
  3. package/dist/fieldValidation/index.d.ts.map +1 -1
  4. package/dist/fieldValidation/index.js +25 -30
  5. package/dist/fieldValidation/public.d.ts +2 -2
  6. package/dist/fieldValidation/public.d.ts.map +1 -1
  7. package/dist/fieldValidation/public.js +1 -1
  8. package/dist/html/index.d.ts +44 -9
  9. package/dist/html/index.d.ts.map +1 -1
  10. package/dist/html/index.js +15 -3
  11. package/dist/html/lazy.d.ts +12 -0
  12. package/dist/html/lazy.d.ts.map +1 -0
  13. package/dist/html/lazy.js +35 -0
  14. package/dist/html/public.d.ts +2 -1
  15. package/dist/html/public.d.ts.map +1 -1
  16. package/dist/html/public.js +1 -0
  17. package/dist/index.d.ts +2 -0
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +2 -0
  20. package/dist/managedResource/index.d.ts +38 -0
  21. package/dist/managedResource/index.d.ts.map +1 -0
  22. package/dist/managedResource/index.js +20 -0
  23. package/dist/managedResource/public.d.ts +5 -0
  24. package/dist/managedResource/public.d.ts.map +1 -0
  25. package/dist/managedResource/public.js +2 -0
  26. package/dist/runtime/managedResource.d.ts +114 -0
  27. package/dist/runtime/managedResource.d.ts.map +1 -0
  28. package/dist/runtime/managedResource.js +92 -0
  29. package/dist/runtime/public.d.ts +2 -2
  30. package/dist/runtime/public.d.ts.map +1 -1
  31. package/dist/runtime/public.js +1 -1
  32. package/dist/runtime/runtime.d.ts +79 -90
  33. package/dist/runtime/runtime.d.ts.map +1 -1
  34. package/dist/runtime/runtime.js +95 -19
  35. package/dist/runtime/subscription.d.ts +25 -0
  36. package/dist/runtime/subscription.d.ts.map +1 -0
  37. package/dist/runtime/subscription.js +7 -0
  38. package/dist/struct/index.d.ts +2 -0
  39. package/dist/struct/index.d.ts.map +1 -1
  40. package/dist/struct/index.js +4 -0
  41. package/dist/struct/public.d.ts +1 -1
  42. package/dist/struct/public.d.ts.map +1 -1
  43. package/dist/struct/public.js +1 -1
  44. package/dist/subscription/public.d.ts +3 -0
  45. package/dist/subscription/public.d.ts.map +1 -0
  46. package/dist/subscription/public.js +1 -0
  47. package/dist/ui/anchor.d.ts +19 -0
  48. package/dist/ui/anchor.d.ts.map +1 -0
  49. package/dist/ui/{menu/anchor.js → anchor.js} +3 -2
  50. package/dist/ui/disclosure/index.d.ts.map +1 -1
  51. package/dist/ui/disclosure/index.js +3 -2
  52. package/dist/ui/index.d.ts +2 -0
  53. package/dist/ui/index.d.ts.map +1 -1
  54. package/dist/ui/index.js +2 -0
  55. package/dist/ui/listbox/multi.d.ts +172 -0
  56. package/dist/ui/listbox/multi.d.ts.map +1 -0
  57. package/dist/ui/listbox/multi.js +25 -0
  58. package/dist/ui/listbox/multiPublic.d.ts +3 -0
  59. package/dist/ui/listbox/multiPublic.d.ts.map +1 -0
  60. package/dist/ui/listbox/multiPublic.js +1 -0
  61. package/dist/ui/listbox/public.d.ts +7 -3
  62. package/dist/ui/listbox/public.d.ts.map +1 -1
  63. package/dist/ui/listbox/public.js +4 -1
  64. package/dist/ui/listbox/{index.d.ts → shared.d.ts} +78 -27
  65. package/dist/ui/listbox/shared.d.ts.map +1 -0
  66. package/dist/ui/listbox/{index.js → shared.js} +208 -199
  67. package/dist/ui/listbox/single.d.ts +172 -0
  68. package/dist/ui/listbox/single.d.ts.map +1 -0
  69. package/dist/ui/listbox/single.js +29 -0
  70. package/dist/ui/menu/index.d.ts +1 -4
  71. package/dist/ui/menu/index.d.ts.map +1 -1
  72. package/dist/ui/menu/index.js +2 -3
  73. package/dist/ui/menu/public.d.ts +3 -2
  74. package/dist/ui/menu/public.d.ts.map +1 -1
  75. package/dist/ui/menu/public.js +2 -1
  76. package/dist/ui/popover/index.d.ts +75 -0
  77. package/dist/ui/popover/index.d.ts.map +1 -0
  78. package/dist/ui/popover/index.js +237 -0
  79. package/dist/ui/popover/public.d.ts +5 -0
  80. package/dist/ui/popover/public.d.ts.map +1 -0
  81. package/dist/ui/popover/public.js +2 -0
  82. package/dist/ui/switch/index.d.ts +47 -0
  83. package/dist/ui/switch/index.d.ts.map +1 -0
  84. package/dist/ui/switch/index.js +66 -0
  85. package/dist/ui/switch/public.d.ts +3 -0
  86. package/dist/ui/switch/public.d.ts.map +1 -0
  87. package/dist/ui/switch/public.js +1 -0
  88. package/dist/ui/transition.d.ts +5 -0
  89. package/dist/ui/transition.d.ts.map +1 -0
  90. package/dist/ui/transition.js +3 -0
  91. package/package.json +17 -1
  92. package/dist/ui/listbox/index.d.ts.map +0 -1
  93. package/dist/ui/menu/anchor.d.ts +0 -18
  94. package/dist/ui/menu/anchor.d.ts.map +0 -1
@@ -2,37 +2,49 @@ import { Array, Effect, Match as M, Option, Predicate, Schema as S, String as St
2
2
  import { OptionExt } from '../../effectExtensions';
3
3
  import { html } from '../../html';
4
4
  import { m } from '../../message';
5
- import { evo } from '../../struct';
5
+ import { makeConstrainedEvo } from '../../struct';
6
6
  import * as Task from '../../task';
7
+ import { anchorHooks } from '../anchor';
7
8
  import { groupContiguous } from '../group';
8
9
  import { findFirstEnabledIndex, isPrintableKey, keyToIndex } from '../keyboard';
9
- import { anchorHooks } from '../menu/anchor';
10
+ import { TransitionState } from '../transition';
10
11
  import { resolveTypeaheadMatch } from '../typeahead';
11
12
  export { resolveTypeaheadMatch };
12
13
  // MODEL
13
14
  /** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
14
15
  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
16
  /** Schema for the listbox orientation — whether items flow vertically or horizontally. */
18
17
  export const Orientation = S.Literal('Vertical', 'Horizontal');
19
- /** Schema for the listbox component's state, tracking open/closed status, active item, selected items, activation trigger, and typeahead search. */
20
- export const Model = S.Struct({
18
+ /** Schema fields shared by all listbox variants (single-select and multi-select). Spread into each variant's `S.Struct` to avoid duplicating field definitions. */
19
+ export const BaseModel = S.Struct({
21
20
  id: S.String,
22
21
  isOpen: S.Boolean,
23
22
  isAnimated: S.Boolean,
24
23
  isModal: S.Boolean,
25
- isMultiple: S.Boolean,
26
24
  orientation: Orientation,
27
25
  transitionState: TransitionState,
28
26
  maybeActiveItemIndex: S.OptionFromSelf(S.Number),
29
27
  activationTrigger: ActivationTrigger,
30
28
  searchQuery: S.String,
31
29
  searchVersion: S.Number,
32
- selectedItems: S.Array(S.String),
33
30
  maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
34
31
  maybeLastButtonPointerType: S.OptionFromSelf(S.String),
35
32
  });
33
+ /** Creates the shared base fields for a listbox model from a config. Each variant spreads this and adds its selection field. */
34
+ export const baseInit = (config) => ({
35
+ id: config.id,
36
+ isOpen: false,
37
+ isAnimated: config.isAnimated ?? false,
38
+ isModal: config.isModal ?? false,
39
+ orientation: config.orientation ?? 'Vertical',
40
+ transitionState: 'Idle',
41
+ maybeActiveItemIndex: Option.none(),
42
+ activationTrigger: 'Keyboard',
43
+ searchQuery: '',
44
+ searchVersion: 0,
45
+ maybeLastPointerPosition: Option.none(),
46
+ maybeLastButtonPointerType: Option.none(),
47
+ });
36
48
  // MESSAGE
37
49
  /** Sent when the listbox opens via button click or keyboard. Contains an optional initial active item index — None for pointer, Some for keyboard. */
38
50
  export const Opened = m('Opened', {
@@ -83,30 +95,19 @@ export const PressedPointerOnButton = m('PressedPointerOnButton', {
83
95
  });
84
96
  /** Union of all messages the listbox component can produce. */
85
97
  export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, DetectedButtonMovement, PressedPointerOnButton);
86
- // INIT
87
- const SEARCH_DEBOUNCE_MILLISECONDS = 350;
88
- const LEFT_MOUSE_BUTTON = 0;
89
- /** Creates an initial listbox model from a config. Defaults to closed with no active item and no selection. */
90
- export const init = (config) => ({
91
- id: config.id,
92
- isOpen: false,
93
- isAnimated: config.isAnimated ?? false,
94
- isModal: config.isModal ?? false,
95
- isMultiple: config.isMultiple ?? false,
96
- orientation: config.orientation ?? 'Vertical',
97
- transitionState: 'Idle',
98
- maybeActiveItemIndex: Option.none(),
99
- activationTrigger: 'Keyboard',
100
- searchQuery: '',
101
- searchVersion: 0,
102
- selectedItems: config.selectedItems ?? [],
103
- maybeLastPointerPosition: Option.none(),
104
- maybeLastButtonPointerType: Option.none(),
105
- });
106
- // UPDATE
107
- const closedModel = (model) => evo(model, {
98
+ // CONSTANTS
99
+ export const SEARCH_DEBOUNCE_MILLISECONDS = 350;
100
+ export const LEFT_MOUSE_BUTTON = 0;
101
+ // SELECTORS
102
+ export const buttonSelector = (id) => `#${id}-button`;
103
+ export const itemsSelector = (id) => `#${id}-items`;
104
+ export const itemSelector = (id, index) => `#${id}-item-${index}`;
105
+ export const itemId = (id, index) => `${id}-item-${index}`;
106
+ // HELPERS
107
+ const constrainedEvo = makeConstrainedEvo();
108
+ export const closedModel = (model) => constrainedEvo(model, {
108
109
  isOpen: () => false,
109
- transitionState: () => (model.isAnimated ? 'LeaveStart' : 'Idle'),
110
+ transitionState: () => model.isAnimated ? 'LeaveStart' : 'Idle',
110
111
  maybeActiveItemIndex: () => Option.none(),
111
112
  activationTrigger: () => 'Keyboard',
112
113
  searchQuery: () => '',
@@ -114,182 +115,187 @@ const closedModel = (model) => evo(model, {
114
115
  maybeLastPointerPosition: () => Option.none(),
115
116
  maybeLastButtonPointerType: () => Option.none(),
116
117
  });
117
- const buttonSelector = (id) => `#${id}-button`;
118
- const itemsSelector = (id) => `#${id}-items`;
119
- const itemSelector = (id, index) => `#${id}-item-${index}`;
120
- const withUpdateReturn = M.withReturnType();
121
- /** Processes a listbox message and returns the next model and commands. */
122
- export const update = (model, message) => {
123
- const maybeNextFrame = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
124
- const maybeLockScroll = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
125
- const maybeUnlockScroll = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
126
- const maybeInertOthers = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
127
- buttonSelector(model.id),
128
- itemsSelector(model.id),
129
- ]).pipe(Effect.as(NoOp())));
130
- const maybeRestoreInert = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
131
- const focusButton = Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp()));
132
- return M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
133
- Opened: ({ maybeActiveItemIndex }) => {
134
- const nextModel = evo(model, {
135
- isOpen: () => true,
136
- transitionState: () => (model.isAnimated ? 'EnterStart' : 'Idle'),
137
- maybeActiveItemIndex: () => maybeActiveItemIndex,
138
- activationTrigger: () => Option.match(maybeActiveItemIndex, {
139
- onNone: () => 'Pointer',
140
- onSome: () => 'Keyboard',
141
- }),
142
- searchQuery: () => '',
143
- searchVersion: () => 0,
144
- maybeLastPointerPosition: () => Option.none(),
145
- });
146
- return [
147
- nextModel,
148
- pipe(Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
149
- ];
150
- },
151
- Closed: () => [
152
- closedModel(model),
153
- pipe(Array.getSomes([
154
- maybeNextFrame,
155
- maybeUnlockScroll,
156
- maybeRestoreInert,
157
- ]), Array.prepend(focusButton)),
158
- ],
159
- ClosedByTab: () => [
160
- closedModel(model),
161
- Array.getSomes([maybeNextFrame, maybeUnlockScroll, maybeRestoreInert]),
162
- ],
163
- ActivatedItem: ({ index, activationTrigger }) => [
164
- evo(model, {
165
- maybeActiveItemIndex: () => Option.some(index),
166
- activationTrigger: () => activationTrigger,
167
- }),
168
- activationTrigger === 'Keyboard'
169
- ? [
170
- Task.scrollIntoView(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
171
- ]
172
- : [],
173
- ],
174
- MovedPointerOverItem: ({ index, screenX, screenY }) => {
175
- const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
176
- if (isSamePosition) {
177
- return [model, []];
178
- }
179
- return [
180
- evo(model, {
181
- maybeActiveItemIndex: () => Option.some(index),
182
- activationTrigger: () => 'Pointer',
183
- maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
184
- }),
185
- [],
186
- ];
187
- },
188
- DeactivatedItem: () => model.activationTrigger === 'Pointer'
189
- ? [evo(model, { maybeActiveItemIndex: () => Option.none() }), []]
190
- : [model, []],
191
- SelectedItem: ({ item }) => {
192
- if (model.isMultiple) {
193
- const nextSelectedItems = Array.contains(model.selectedItems, item)
194
- ? Array.filter(model.selectedItems, selected => selected !== item)
195
- : Array.append(model.selectedItems, item);
196
- return [evo(model, { selectedItems: () => nextSelectedItems }), []];
197
- }
198
- return [
199
- evo(closedModel(model), {
200
- selectedItems: () => [item],
201
- }),
118
+ export const makeUpdate = (handleSelectedItem) => {
119
+ const withUpdateReturn = M.withReturnType();
120
+ return (model, message) => {
121
+ const maybeNextFrame = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
122
+ const maybeLockScroll = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
123
+ const maybeUnlockScroll = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
124
+ const maybeInertOthers = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
125
+ buttonSelector(model.id),
126
+ itemsSelector(model.id),
127
+ ]).pipe(Effect.as(NoOp())));
128
+ const maybeRestoreInert = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
129
+ const focusButton = Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp()));
130
+ return M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
131
+ Opened: ({ maybeActiveItemIndex }) => {
132
+ const nextModel = constrainedEvo(model, {
133
+ isOpen: () => true,
134
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
135
+ maybeActiveItemIndex: () => maybeActiveItemIndex,
136
+ activationTrigger: () => Option.match(maybeActiveItemIndex, {
137
+ onNone: () => 'Pointer',
138
+ onSome: () => 'Keyboard',
139
+ }),
140
+ searchQuery: () => '',
141
+ searchVersion: () => 0,
142
+ maybeLastPointerPosition: () => Option.none(),
143
+ });
144
+ return [
145
+ nextModel,
146
+ pipe(Array.getSomes([
147
+ maybeNextFrame,
148
+ maybeLockScroll,
149
+ maybeInertOthers,
150
+ ]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
151
+ ];
152
+ },
153
+ Closed: () => [
154
+ closedModel(model),
202
155
  pipe(Array.getSomes([
203
156
  maybeNextFrame,
204
157
  maybeUnlockScroll,
205
158
  maybeRestoreInert,
206
159
  ]), Array.prepend(focusButton)),
207
- ];
208
- },
209
- RequestedItemClick: ({ index }) => [
210
- model,
211
- [
212
- Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
213
160
  ],
214
- ],
215
- Searched: ({ key, maybeTargetIndex }) => {
216
- const nextSearchQuery = model.searchQuery + key;
217
- const nextSearchVersion = model.searchVersion + 1;
218
- return [
219
- evo(model, {
220
- searchQuery: () => nextSearchQuery,
221
- searchVersion: () => nextSearchVersion,
222
- maybeActiveItemIndex: () => Option.orElse(maybeTargetIndex, () => model.maybeActiveItemIndex),
161
+ ClosedByTab: () => [
162
+ closedModel(model),
163
+ Array.getSomes([
164
+ maybeNextFrame,
165
+ maybeUnlockScroll,
166
+ maybeRestoreInert,
167
+ ]),
168
+ ],
169
+ ActivatedItem: ({ index, activationTrigger }) => [
170
+ constrainedEvo(model, {
171
+ maybeActiveItemIndex: () => Option.some(index),
172
+ activationTrigger: () => activationTrigger,
223
173
  }),
174
+ activationTrigger === 'Keyboard'
175
+ ? [
176
+ Task.scrollIntoView(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
177
+ ]
178
+ : [],
179
+ ],
180
+ MovedPointerOverItem: ({ index, screenX, screenY }) => {
181
+ const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
182
+ if (isSamePosition) {
183
+ return [model, []];
184
+ }
185
+ return [
186
+ constrainedEvo(model, {
187
+ maybeActiveItemIndex: () => Option.some(index),
188
+ activationTrigger: () => 'Pointer',
189
+ maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
190
+ }),
191
+ [],
192
+ ];
193
+ },
194
+ DeactivatedItem: () => model.activationTrigger === 'Pointer'
195
+ ? [
196
+ constrainedEvo(model, {
197
+ maybeActiveItemIndex: () => Option.none(),
198
+ }),
199
+ [],
200
+ ]
201
+ : [model, []],
202
+ SelectedItem: ({ item }) => handleSelectedItem(model, item, {
203
+ focusButton,
204
+ maybeNextFrame,
205
+ maybeUnlockScroll,
206
+ maybeRestoreInert,
207
+ }),
208
+ RequestedItemClick: ({ index }) => [
209
+ model,
224
210
  [
225
- Task.delay(SEARCH_DEBOUNCE_MILLISECONDS).pipe(Effect.as(ClearedSearch({ version: nextSearchVersion }))),
211
+ Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
226
212
  ],
227
- ];
228
- },
229
- ClearedSearch: ({ version }) => {
230
- if (version !== model.searchVersion) {
231
- return [model, []];
232
- }
233
- return [evo(model, { searchQuery: () => '' }), []];
234
- },
235
- AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('EnterStart', () => [
236
- evo(model, { transitionState: () => 'EnterAnimating' }),
237
- [
238
- Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
239
- ],
240
- ]), M.when('LeaveStart', () => [
241
- evo(model, { transitionState: () => 'LeaveAnimating' }),
242
- [
243
- Effect.raceFirst(Task.detectElementMovement(buttonSelector(model.id)).pipe(Effect.as(DetectedButtonMovement())), Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition()))),
244
213
  ],
245
- ]), M.orElse(() => [model, []])),
246
- EndedTransition: () => M.value(model.transitionState).pipe(withUpdateReturn, M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
247
- evo(model, { transitionState: () => 'Idle' }),
248
- [],
249
- ]), M.orElse(() => [model, []])),
250
- DetectedButtonMovement: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('LeaveAnimating', () => [
251
- evo(model, { transitionState: () => 'Idle' }),
252
- [],
253
- ]), M.orElse(() => [model, []])),
254
- PressedPointerOnButton: ({ pointerType, button }) => {
255
- const withPointerType = evo(model, {
256
- maybeLastButtonPointerType: () => Option.some(pointerType),
257
- });
258
- if (pointerType !== 'mouse' || button !== LEFT_MOUSE_BUTTON) {
259
- return [withPointerType, []];
260
- }
261
- if (model.isOpen) {
214
+ Searched: ({ key, maybeTargetIndex }) => {
215
+ const nextSearchQuery = model.searchQuery + key;
216
+ const nextSearchVersion = model.searchVersion + 1;
217
+ return [
218
+ constrainedEvo(model, {
219
+ searchQuery: () => nextSearchQuery,
220
+ searchVersion: () => nextSearchVersion,
221
+ maybeActiveItemIndex: () => Option.orElse(maybeTargetIndex, () => model.maybeActiveItemIndex),
222
+ }),
223
+ [
224
+ Task.delay(SEARCH_DEBOUNCE_MILLISECONDS).pipe(Effect.as(ClearedSearch({ version: nextSearchVersion }))),
225
+ ],
226
+ ];
227
+ },
228
+ ClearedSearch: ({ version }) => {
229
+ if (version !== model.searchVersion) {
230
+ return [model, []];
231
+ }
232
+ return [constrainedEvo(model, { searchQuery: () => '' }), []];
233
+ },
234
+ AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('EnterStart', () => [
235
+ constrainedEvo(model, {
236
+ transitionState: () => 'EnterAnimating',
237
+ }),
238
+ [
239
+ Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
240
+ ],
241
+ ]), M.when('LeaveStart', () => [
242
+ constrainedEvo(model, {
243
+ transitionState: () => 'LeaveAnimating',
244
+ }),
245
+ [
246
+ Effect.raceFirst(Task.detectElementMovement(buttonSelector(model.id)).pipe(Effect.as(DetectedButtonMovement())), Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition()))),
247
+ ],
248
+ ]), M.orElse(() => [model, []])),
249
+ EndedTransition: () => M.value(model.transitionState).pipe(withUpdateReturn, M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
250
+ constrainedEvo(model, { transitionState: () => 'Idle' }),
251
+ [],
252
+ ]), M.orElse(() => [model, []])),
253
+ DetectedButtonMovement: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('LeaveAnimating', () => [
254
+ constrainedEvo(model, { transitionState: () => 'Idle' }),
255
+ [],
256
+ ]), M.orElse(() => [model, []])),
257
+ PressedPointerOnButton: ({ pointerType, button }) => {
258
+ const withPointerType = constrainedEvo(model, {
259
+ maybeLastButtonPointerType: () => Option.some(pointerType),
260
+ });
261
+ if (pointerType !== 'mouse' || button !== LEFT_MOUSE_BUTTON) {
262
+ return [withPointerType, []];
263
+ }
264
+ if (model.isOpen) {
265
+ return [
266
+ closedModel(withPointerType),
267
+ pipe(Array.getSomes([
268
+ maybeNextFrame,
269
+ maybeUnlockScroll,
270
+ maybeRestoreInert,
271
+ ]), Array.prepend(focusButton)),
272
+ ];
273
+ }
274
+ const nextModel = constrainedEvo(withPointerType, {
275
+ isOpen: () => true,
276
+ transitionState: () => model.isAnimated ? 'EnterStart' : 'Idle',
277
+ maybeActiveItemIndex: () => Option.none(),
278
+ activationTrigger: () => 'Pointer',
279
+ searchQuery: () => '',
280
+ searchVersion: () => 0,
281
+ maybeLastPointerPosition: () => Option.none(),
282
+ });
262
283
  return [
263
- closedModel(withPointerType),
284
+ nextModel,
264
285
  pipe(Array.getSomes([
265
286
  maybeNextFrame,
266
- maybeUnlockScroll,
267
- maybeRestoreInert,
268
- ]), Array.prepend(focusButton)),
287
+ maybeLockScroll,
288
+ maybeInertOthers,
289
+ ]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
269
290
  ];
270
- }
271
- const nextModel = evo(withPointerType, {
272
- isOpen: () => true,
273
- transitionState: () => (model.isAnimated ? 'EnterStart' : 'Idle'),
274
- maybeActiveItemIndex: () => Option.none(),
275
- activationTrigger: () => 'Pointer',
276
- searchQuery: () => '',
277
- searchVersion: () => 0,
278
- maybeLastPointerPosition: () => Option.none(),
279
- });
280
- return [
281
- nextModel,
282
- pipe(Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
283
- ];
284
- },
285
- NoOp: () => [model, []],
286
- }));
291
+ },
292
+ NoOp: () => [model, []],
293
+ }));
294
+ };
287
295
  };
288
- const itemId = (id, index) => `${id}-item-${index}`;
289
- /** Renders a headless listbox with typeahead search, keyboard navigation, selection tracking, and aria-activedescendant focus management. */
290
- export const view = (config) => {
291
- const { div, input, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, AriaOrientation, AriaSelected, Attribute, Class, DataAttribute, Id, Name, OnBlur, OnClick, OnDestroy, OnInsert, OnKeyDownPreventDefault, OnKeyUpPreventDefault, OnPointerDown, OnPointerLeave, OnPointerMove, Role, Style, Tabindex, Type, Value, keyed, } = html();
292
- const { model: { id, isOpen, isMultiple, orientation, transitionState, maybeActiveItemIndex, selectedItems, searchQuery, maybeLastButtonPointerType, }, toMessage, items, itemToConfig, isItemDisabled, isButtonDisabled, buttonContent, buttonClassName, itemsClassName, backdropClassName, className, itemGroupKey, groupToHeading, groupClassName, separatorClassName, anchor, name, form, isDisabled, isInvalid, } = config;
296
+ export const makeView = (behavior) => (config) => {
297
+ 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();
298
+ 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;
293
299
  const itemToValue = config.itemToValue ?? (item => String(item));
294
300
  const itemToSearchText = config.itemToSearchText ?? (item => itemToValue(item));
295
301
  const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
@@ -325,7 +331,7 @@ export const view = (config) => {
325
331
  const isNavigationKey = (key) => Array.contains(navigationKeys, key);
326
332
  const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(0, 1);
327
333
  const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(items.length - 1, -1);
328
- const selectedItemIndex = pipe(Array.head(selectedItems), Option.flatMap(selectedItem => Array.findFirstIndex(items, item => itemToValue(item) === selectedItem)));
334
+ const selectedItemIndex = behavior.selectedItemIndex(config.model, items, itemToValue);
329
335
  const handleButtonKeyDown = (key) => M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(toMessage(Opened({
330
336
  maybeActiveItemIndex: Option.orElse(selectedItemIndex, () => Option.some(firstEnabledIndex)),
331
337
  })))), M.when('ArrowUp', () => Option.some(toMessage(Opened({
@@ -399,7 +405,7 @@ export const view = (config) => {
399
405
  Id(`${id}-items`),
400
406
  Role('listbox'),
401
407
  AriaOrientation(Str.toLowerCase(orientation)),
402
- ...(isMultiple ? [Attribute('aria-multiselectable', 'true')] : []),
408
+ ...(behavior.ariaMultiSelectable ? [AriaMultiSelectable(true)] : []),
403
409
  AriaLabelledBy(`${id}-button`),
404
410
  ...maybeActiveDescendant,
405
411
  Tabindex(0),
@@ -417,7 +423,7 @@ export const view = (config) => {
417
423
  const listboxItems = Array.map(items, (item, index) => {
418
424
  const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
419
425
  const isDisabledItem = isItemDisabledByIndex(index);
420
- const isSelectedItem = Array.contains(selectedItems, itemToValue(item));
426
+ const isSelectedItem = behavior.isItemSelected(config.model, itemToValue(item));
421
427
  const itemConfig = itemToConfig(item, {
422
428
  isActive: isActiveItem,
423
429
  isDisabled: isDisabledItem,
@@ -484,13 +490,16 @@ export const view = (config) => {
484
490
  keyed('div')(`${id}-items-container`, itemsContainerAttributes, renderedItems),
485
491
  ];
486
492
  const formAttribute = form ? [Attribute('form', form)] : [];
493
+ const selectedValues = pipe(items, Array.filter(item => behavior.isItemSelected(config.model, itemToValue(item))), Array.map(itemToValue));
487
494
  const hiddenInputs = name
488
- ? Array.match(selectedItems, {
489
- onEmpty: () => [input([Type('hidden'), Name(name), ...formAttribute])],
490
- onNonEmpty: Array.map(selectedItem => input([
495
+ ? Array.match(selectedValues, {
496
+ onEmpty: () => [
497
+ input([Type('hidden'), Name(name), ...formAttribute]),
498
+ ],
499
+ onNonEmpty: Array.map(selectedValue => input([
491
500
  Type('hidden'),
492
501
  Name(name),
493
- Value(selectedItem),
502
+ Value(selectedValue),
494
503
  ...formAttribute,
495
504
  ])),
496
505
  })