foldkit 0.24.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 (61) 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/listbox/multi.d.ts +172 -0
  46. package/dist/ui/listbox/multi.d.ts.map +1 -0
  47. package/dist/ui/listbox/multi.js +25 -0
  48. package/dist/ui/listbox/multiPublic.d.ts +3 -0
  49. package/dist/ui/listbox/multiPublic.d.ts.map +1 -0
  50. package/dist/ui/listbox/multiPublic.js +1 -0
  51. package/dist/ui/listbox/public.d.ts +5 -2
  52. package/dist/ui/listbox/public.d.ts.map +1 -1
  53. package/dist/ui/listbox/public.js +3 -1
  54. package/dist/ui/listbox/{index.d.ts → shared.d.ts} +77 -23
  55. package/dist/ui/listbox/shared.d.ts.map +1 -0
  56. package/dist/ui/listbox/{index.js → shared.js} +206 -196
  57. package/dist/ui/listbox/single.d.ts +172 -0
  58. package/dist/ui/listbox/single.d.ts.map +1 -0
  59. package/dist/ui/listbox/single.js +29 -0
  60. package/package.json +9 -1
  61. package/dist/ui/listbox/index.d.ts.map +0 -1
@@ -2,7 +2,7 @@ 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
7
  import { groupContiguous } from '../group';
8
8
  import { findFirstEnabledIndex, isPrintableKey, keyToIndex } from '../keyboard';
@@ -16,23 +16,36 @@ export const ActivationTrigger = S.Literal('Pointer', 'Keyboard');
16
16
  export const TransitionState = S.Literal('Idle', 'EnterStart', 'EnterAnimating', 'LeaveStart', 'LeaveAnimating');
17
17
  /** Schema for the listbox orientation — whether items flow vertically or horizontally. */
18
18
  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({
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
21
  id: S.String,
22
22
  isOpen: S.Boolean,
23
23
  isAnimated: S.Boolean,
24
24
  isModal: S.Boolean,
25
- isMultiple: S.Boolean,
26
25
  orientation: Orientation,
27
26
  transitionState: TransitionState,
28
27
  maybeActiveItemIndex: S.OptionFromSelf(S.Number),
29
28
  activationTrigger: ActivationTrigger,
30
29
  searchQuery: S.String,
31
30
  searchVersion: S.Number,
32
- selectedItems: S.Array(S.String),
33
31
  maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
34
32
  maybeLastButtonPointerType: S.OptionFromSelf(S.String),
35
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
+ });
36
49
  // MESSAGE
37
50
  /** Sent when the listbox opens via button click or keyboard. Contains an optional initial active item index — None for pointer, Some for keyboard. */
38
51
  export const Opened = m('Opened', {
@@ -83,30 +96,19 @@ export const PressedPointerOnButton = m('PressedPointerOnButton', {
83
96
  });
84
97
  /** Union of all messages the listbox component can produce. */
85
98
  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, {
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, {
108
110
  isOpen: () => false,
109
- transitionState: () => (model.isAnimated ? 'LeaveStart' : 'Idle'),
111
+ transitionState: () => model.isAnimated ? 'LeaveStart' : 'Idle',
110
112
  maybeActiveItemIndex: () => Option.none(),
111
113
  activationTrigger: () => 'Keyboard',
112
114
  searchQuery: () => '',
@@ -114,182 +116,187 @@ const closedModel = (model) => evo(model, {
114
116
  maybeLastPointerPosition: () => Option.none(),
115
117
  maybeLastButtonPointerType: () => Option.none(),
116
118
  });
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
- }),
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),
202
156
  pipe(Array.getSomes([
203
157
  maybeNextFrame,
204
158
  maybeUnlockScroll,
205
159
  maybeRestoreInert,
206
160
  ]), 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
161
  ],
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),
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,
223
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,
224
211
  [
225
- Task.delay(SEARCH_DEBOUNCE_MILLISECONDS).pipe(Effect.as(ClearedSearch({ version: nextSearchVersion }))),
212
+ Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
226
213
  ],
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
214
  ],
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) {
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
+ });
262
284
  return [
263
- closedModel(withPointerType),
285
+ nextModel,
264
286
  pipe(Array.getSomes([
265
287
  maybeNextFrame,
266
- maybeUnlockScroll,
267
- maybeRestoreInert,
268
- ]), Array.prepend(focusButton)),
288
+ maybeLockScroll,
289
+ maybeInertOthers,
290
+ ]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
269
291
  ];
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
- }));
292
+ },
293
+ NoOp: () => [model, []],
294
+ }));
295
+ };
287
296
  };
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;
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;
293
300
  const itemToValue = config.itemToValue ?? (item => String(item));
294
301
  const itemToSearchText = config.itemToSearchText ?? (item => itemToValue(item));
295
302
  const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
@@ -325,7 +332,7 @@ export const view = (config) => {
325
332
  const isNavigationKey = (key) => Array.contains(navigationKeys, key);
326
333
  const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(0, 1);
327
334
  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)));
335
+ const selectedItemIndex = behavior.selectedItemIndex(config.model, items, itemToValue);
329
336
  const handleButtonKeyDown = (key) => M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(toMessage(Opened({
330
337
  maybeActiveItemIndex: Option.orElse(selectedItemIndex, () => Option.some(firstEnabledIndex)),
331
338
  })))), M.when('ArrowUp', () => Option.some(toMessage(Opened({
@@ -399,7 +406,7 @@ export const view = (config) => {
399
406
  Id(`${id}-items`),
400
407
  Role('listbox'),
401
408
  AriaOrientation(Str.toLowerCase(orientation)),
402
- ...(isMultiple ? [Attribute('aria-multiselectable', 'true')] : []),
409
+ ...(behavior.ariaMultiSelectable ? [AriaMultiSelectable(true)] : []),
403
410
  AriaLabelledBy(`${id}-button`),
404
411
  ...maybeActiveDescendant,
405
412
  Tabindex(0),
@@ -417,7 +424,7 @@ export const view = (config) => {
417
424
  const listboxItems = Array.map(items, (item, index) => {
418
425
  const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
419
426
  const isDisabledItem = isItemDisabledByIndex(index);
420
- const isSelectedItem = Array.contains(selectedItems, itemToValue(item));
427
+ const isSelectedItem = behavior.isItemSelected(config.model, itemToValue(item));
421
428
  const itemConfig = itemToConfig(item, {
422
429
  isActive: isActiveItem,
423
430
  isDisabled: isDisabledItem,
@@ -484,13 +491,16 @@ export const view = (config) => {
484
491
  keyed('div')(`${id}-items-container`, itemsContainerAttributes, renderedItems),
485
492
  ];
486
493
  const formAttribute = form ? [Attribute('form', form)] : [];
494
+ const selectedValues = pipe(items, Array.filter(item => behavior.isItemSelected(config.model, itemToValue(item))), Array.map(itemToValue));
487
495
  const hiddenInputs = name
488
- ? Array.match(selectedItems, {
489
- onEmpty: () => [input([Type('hidden'), Name(name), ...formAttribute])],
490
- onNonEmpty: Array.map(selectedItem => input([
496
+ ? Array.match(selectedValues, {
497
+ onEmpty: () => [
498
+ input([Type('hidden'), Name(name), ...formAttribute]),
499
+ ],
500
+ onNonEmpty: Array.map(selectedValue => input([
491
501
  Type('hidden'),
492
502
  Name(name),
493
- Value(selectedItem),
503
+ Value(selectedValue),
494
504
  ...formAttribute,
495
505
  ])),
496
506
  })