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.
- package/dist/fieldValidation/index.d.ts +3 -4
- package/dist/fieldValidation/index.d.ts.map +1 -1
- package/dist/fieldValidation/index.js +11 -15
- package/dist/html/index.d.ts +42 -0
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +15 -3
- package/dist/html/lazy.d.ts +12 -0
- package/dist/html/lazy.d.ts.map +1 -0
- package/dist/html/lazy.js +35 -0
- package/dist/html/public.d.ts +1 -0
- package/dist/html/public.d.ts.map +1 -1
- package/dist/html/public.js +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/managedResource/index.d.ts +38 -0
- package/dist/managedResource/index.d.ts.map +1 -0
- package/dist/managedResource/index.js +20 -0
- package/dist/managedResource/public.d.ts +5 -0
- package/dist/managedResource/public.d.ts.map +1 -0
- package/dist/managedResource/public.js +2 -0
- package/dist/runtime/managedResource.d.ts +114 -0
- package/dist/runtime/managedResource.d.ts.map +1 -0
- package/dist/runtime/managedResource.js +92 -0
- package/dist/runtime/public.d.ts +2 -2
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/public.js +1 -1
- package/dist/runtime/runtime.d.ts +79 -90
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +95 -19
- package/dist/runtime/subscription.d.ts +25 -0
- package/dist/runtime/subscription.d.ts.map +1 -0
- package/dist/runtime/subscription.js +7 -0
- package/dist/struct/index.d.ts +2 -0
- package/dist/struct/index.d.ts.map +1 -1
- package/dist/struct/index.js +4 -0
- package/dist/struct/public.d.ts +1 -1
- package/dist/struct/public.d.ts.map +1 -1
- package/dist/struct/public.js +1 -1
- package/dist/subscription/public.d.ts +3 -0
- package/dist/subscription/public.d.ts.map +1 -0
- package/dist/subscription/public.js +1 -0
- package/dist/ui/disclosure/index.d.ts.map +1 -1
- package/dist/ui/disclosure/index.js +3 -2
- package/dist/ui/group.d.ts +8 -0
- package/dist/ui/group.d.ts.map +1 -0
- package/dist/ui/group.js +13 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/keyboard.d.ts +2 -0
- package/dist/ui/keyboard.d.ts.map +1 -1
- package/dist/ui/keyboard.js +2 -0
- package/dist/ui/listbox/multi.d.ts +172 -0
- package/dist/ui/listbox/multi.d.ts.map +1 -0
- package/dist/ui/listbox/multi.js +25 -0
- package/dist/ui/listbox/multiPublic.d.ts +3 -0
- package/dist/ui/listbox/multiPublic.d.ts.map +1 -0
- package/dist/ui/listbox/multiPublic.js +1 -0
- package/dist/ui/listbox/public.d.ts +7 -0
- package/dist/ui/listbox/public.d.ts.map +1 -0
- package/dist/ui/listbox/public.js +3 -0
- package/dist/ui/listbox/shared.d.ts +236 -0
- package/dist/ui/listbox/shared.d.ts.map +1 -0
- package/dist/ui/listbox/shared.js +519 -0
- package/dist/ui/listbox/single.d.ts +172 -0
- package/dist/ui/listbox/single.d.ts.map +1 -0
- package/dist/ui/listbox/single.js +29 -0
- package/dist/ui/menu/index.d.ts +4 -9
- package/dist/ui/menu/index.d.ts.map +1 -1
- package/dist/ui/menu/index.js +9 -29
- package/dist/ui/typeahead.d.ts +4 -0
- package/dist/ui/typeahead.d.ts.map +1 -0
- package/dist/ui/typeahead.js +14 -0
- 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
|
+
};
|