foldkit 0.18.0 → 0.20.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/README.md +14 -14
- package/dist/command/index.d.ts +4 -0
- package/dist/command/index.d.ts.map +1 -0
- package/dist/command/index.js +1 -0
- package/dist/command/public.d.ts +2 -0
- package/dist/command/public.d.ts.map +1 -0
- package/dist/command/public.js +1 -0
- package/dist/effectExtensions/optionExtensions.d.ts +4 -0
- package/dist/effectExtensions/optionExtensions.d.ts.map +1 -1
- package/dist/effectExtensions/optionExtensions.js +2 -1
- package/dist/fieldValidation/index.d.ts.map +1 -1
- package/dist/fieldValidation/index.js +4 -4
- package/dist/html/index.d.ts +45 -3
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +53 -7
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/message/index.d.ts +2 -0
- package/dist/message/index.d.ts.map +1 -0
- package/dist/message/index.js +1 -0
- package/dist/message/public.d.ts +2 -0
- package/dist/message/public.d.ts.map +1 -0
- package/dist/message/public.js +1 -0
- package/dist/route/index.d.ts +1 -0
- package/dist/route/index.d.ts.map +1 -1
- package/dist/route/index.js +1 -0
- package/dist/route/parser.js +17 -17
- package/dist/route/public.d.ts +1 -1
- package/dist/route/public.d.ts.map +1 -1
- package/dist/route/public.js +1 -1
- package/dist/runtime/public.d.ts +1 -1
- package/dist/runtime/public.d.ts.map +1 -1
- package/dist/runtime/runtime.d.ts +2 -3
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +9 -9
- package/dist/schema/index.d.ts +36 -8
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +6 -0
- package/dist/task/dom.d.ts +58 -0
- package/dist/task/dom.d.ts.map +1 -0
- package/dist/task/dom.js +112 -0
- package/dist/task/error.d.ts +18 -0
- package/dist/task/error.d.ts.map +1 -0
- package/dist/task/error.js +7 -0
- package/dist/task/index.d.ts +7 -108
- package/dist/task/index.d.ts.map +1 -1
- package/dist/task/index.js +7 -168
- package/dist/task/inert.d.ts +26 -0
- package/dist/task/inert.d.ts.map +1 -0
- package/dist/task/inert.js +87 -0
- package/dist/task/public.d.ts +1 -1
- package/dist/task/public.d.ts.map +1 -1
- package/dist/task/public.js +1 -1
- package/dist/task/random.d.ts +11 -0
- package/dist/task/random.d.ts.map +1 -0
- package/dist/task/random.js +10 -0
- package/dist/task/scrollLock.d.ts +24 -0
- package/dist/task/scrollLock.d.ts.map +1 -0
- package/dist/task/scrollLock.js +46 -0
- package/dist/task/time.d.ts +43 -0
- package/dist/task/time.d.ts.map +1 -0
- package/dist/task/time.js +53 -0
- package/dist/task/timing.d.ts +35 -0
- package/dist/task/timing.d.ts.map +1 -0
- package/dist/task/timing.js +51 -0
- package/dist/ui/dialog/index.d.ts +1 -1
- package/dist/ui/dialog/index.d.ts.map +1 -1
- package/dist/ui/dialog/index.js +7 -7
- package/dist/ui/disclosure/index.d.ts +1 -1
- package/dist/ui/disclosure/index.d.ts.map +1 -1
- package/dist/ui/disclosure/index.js +7 -7
- package/dist/ui/keyboard.d.ts.map +1 -1
- package/dist/ui/keyboard.js +1 -1
- package/dist/ui/menu/index.d.ts +71 -18
- package/dist/ui/menu/index.d.ts.map +1 -1
- package/dist/ui/menu/index.js +325 -113
- package/dist/ui/menu/public.d.ts +2 -2
- package/dist/ui/menu/public.d.ts.map +1 -1
- package/dist/ui/menu/public.js +1 -1
- package/dist/ui/tabs/index.d.ts +4 -5
- package/dist/ui/tabs/index.d.ts.map +1 -1
- package/dist/ui/tabs/index.js +12 -14
- package/dist/url/index.js +4 -4
- package/package.json +13 -1
package/dist/ui/menu/index.js
CHANGED
|
@@ -1,162 +1,320 @@
|
|
|
1
|
-
import { Array, Match as M, Option, Schema as S, String as Str, pipe, } from 'effect';
|
|
1
|
+
import { Array, Effect, Match as M, Option, Schema as S, String as Str, pipe, } from 'effect';
|
|
2
|
+
import { OptionExt } from '../../effectExtensions';
|
|
2
3
|
import { html } from '../../html';
|
|
3
|
-
import {
|
|
4
|
+
import { m } from '../../message';
|
|
4
5
|
import { evo } from '../../struct';
|
|
5
6
|
import * as Task from '../../task';
|
|
6
7
|
import { findFirstEnabledIndex, keyToIndex, wrapIndex } from '../keyboard';
|
|
7
8
|
// MODEL
|
|
8
9
|
/** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
|
|
9
10
|
export const ActivationTrigger = S.Literal('Pointer', 'Keyboard');
|
|
11
|
+
/** Schema for the transition animation state, tracking enter/leave phases for CSS transition coordination. */
|
|
12
|
+
export const TransitionState = S.Literal('Idle', 'EnterStart', 'EnterAnimating', 'LeaveStart', 'LeaveAnimating');
|
|
13
|
+
const PointerOrigin = S.Struct({
|
|
14
|
+
screenX: S.Number,
|
|
15
|
+
screenY: S.Number,
|
|
16
|
+
timeStamp: S.Number,
|
|
17
|
+
});
|
|
10
18
|
/** Schema for the menu component's state, tracking open/closed status, active item, activation trigger, and typeahead search. */
|
|
11
19
|
export const Model = S.Struct({
|
|
12
20
|
id: S.String,
|
|
13
21
|
isOpen: S.Boolean,
|
|
22
|
+
isAnimated: S.Boolean,
|
|
23
|
+
isModal: S.Boolean,
|
|
24
|
+
transitionState: TransitionState,
|
|
14
25
|
maybeActiveItemIndex: S.OptionFromSelf(S.Number),
|
|
15
26
|
activationTrigger: ActivationTrigger,
|
|
16
27
|
searchQuery: S.String,
|
|
17
28
|
searchVersion: S.Number,
|
|
18
29
|
maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
|
|
30
|
+
maybeLastButtonPointerType: S.OptionFromSelf(S.String),
|
|
31
|
+
maybePointerOrigin: S.OptionFromSelf(PointerOrigin),
|
|
19
32
|
});
|
|
20
33
|
// MESSAGE
|
|
21
34
|
/** Sent when the menu opens via button click or keyboard. Contains an optional initial active item index — None for pointer, Some for keyboard. */
|
|
22
|
-
export const Opened =
|
|
35
|
+
export const Opened = m('Opened', {
|
|
23
36
|
maybeActiveItemIndex: S.OptionFromSelf(S.Number),
|
|
24
37
|
});
|
|
25
38
|
/** Sent when the menu closes via Escape key or backdrop click. */
|
|
26
|
-
export const Closed =
|
|
39
|
+
export const Closed = m('Closed');
|
|
27
40
|
/** Sent when focus leaves the menu items container via Tab key. */
|
|
28
|
-
export const ClosedByTab =
|
|
41
|
+
export const ClosedByTab = m('ClosedByTab');
|
|
29
42
|
/** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger. */
|
|
30
|
-
export const
|
|
43
|
+
export const ActivatedItem = m('ActivatedItem', {
|
|
31
44
|
index: S.Number,
|
|
32
45
|
activationTrigger: ActivationTrigger,
|
|
33
46
|
});
|
|
34
47
|
/** Sent when the mouse leaves an enabled item. */
|
|
35
|
-
export const
|
|
48
|
+
export const DeactivatedItem = m('DeactivatedItem');
|
|
36
49
|
/** Sent when an item is selected via Enter, Space, or click. */
|
|
37
|
-
export const
|
|
50
|
+
export const SelectedItem = m('SelectedItem', { index: S.Number });
|
|
51
|
+
/** Sent when Enter or Space is pressed on the active item, triggering a programmatic click on the DOM element. */
|
|
52
|
+
export const RequestedItemClick = m('RequestedItemClick', {
|
|
53
|
+
index: S.Number,
|
|
54
|
+
});
|
|
38
55
|
/** Sent when a printable character is typed for typeahead search. */
|
|
39
|
-
export const Searched =
|
|
56
|
+
export const Searched = m('Searched', {
|
|
40
57
|
key: S.String,
|
|
41
58
|
maybeTargetIndex: S.OptionFromSelf(S.Number),
|
|
42
59
|
});
|
|
43
60
|
/** Sent after the search debounce period to clear the accumulated query. */
|
|
44
|
-
export const
|
|
61
|
+
export const ClearedSearch = m('ClearedSearch', { version: S.Number });
|
|
45
62
|
/** Sent when the pointer moves over a menu item, carrying screen coordinates for tracked-pointer comparison. */
|
|
46
|
-
export const
|
|
63
|
+
export const MovedPointerOverItem = m('MovedPointerOverItem', {
|
|
47
64
|
index: S.Number,
|
|
48
65
|
screenX: S.Number,
|
|
49
66
|
screenY: S.Number,
|
|
50
67
|
});
|
|
51
68
|
/** Placeholder message used when no action is needed. */
|
|
52
|
-
export const NoOp =
|
|
69
|
+
export const NoOp = m('NoOp');
|
|
70
|
+
/** Sent internally when a double-rAF completes, advancing the transition to its animating phase. */
|
|
71
|
+
export const AdvancedTransitionFrame = m('AdvancedTransitionFrame');
|
|
72
|
+
/** Sent internally when all CSS transitions on the menu items container have completed. */
|
|
73
|
+
export const EndedTransition = m('EndedTransition');
|
|
74
|
+
/** Sent when the user presses a pointer device on the menu button. Records pointer type and toggles for mouse. */
|
|
75
|
+
export const PressedPointerOnButton = m('PressedPointerOnButton', {
|
|
76
|
+
pointerType: S.String,
|
|
77
|
+
button: S.Number,
|
|
78
|
+
screenX: S.Number,
|
|
79
|
+
screenY: S.Number,
|
|
80
|
+
timeStamp: S.Number,
|
|
81
|
+
});
|
|
82
|
+
/** Sent when the user releases a pointer on the items container, enabling drag-to-select for mouse. */
|
|
83
|
+
export const ReleasedPointerOnItems = m('ReleasedPointerOnItems', {
|
|
84
|
+
screenX: S.Number,
|
|
85
|
+
screenY: S.Number,
|
|
86
|
+
timeStamp: S.Number,
|
|
87
|
+
});
|
|
53
88
|
/** Union of all messages the menu component can produce. */
|
|
54
|
-
export const Message = S.Union(Opened, Closed, ClosedByTab,
|
|
89
|
+
export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, PressedPointerOnButton, ReleasedPointerOnItems);
|
|
55
90
|
// INIT
|
|
56
91
|
const SEARCH_DEBOUNCE_MILLISECONDS = 350;
|
|
92
|
+
const POINTER_HOLD_THRESHOLD_MILLISECONDS = 200;
|
|
93
|
+
const POINTER_MOVEMENT_THRESHOLD_PIXELS = 5;
|
|
57
94
|
/** Creates an initial menu model from a config. Defaults to closed with no active item. */
|
|
58
95
|
export const init = (config) => ({
|
|
59
96
|
id: config.id,
|
|
60
97
|
isOpen: false,
|
|
98
|
+
isAnimated: config.isAnimated ?? false,
|
|
99
|
+
isModal: config.isModal ?? true,
|
|
100
|
+
transitionState: 'Idle',
|
|
61
101
|
maybeActiveItemIndex: Option.none(),
|
|
62
102
|
activationTrigger: 'Keyboard',
|
|
63
103
|
searchQuery: '',
|
|
64
104
|
searchVersion: 0,
|
|
65
105
|
maybeLastPointerPosition: Option.none(),
|
|
106
|
+
maybeLastButtonPointerType: Option.none(),
|
|
107
|
+
maybePointerOrigin: Option.none(),
|
|
66
108
|
});
|
|
67
109
|
// UPDATE
|
|
68
110
|
const closedModel = (model) => evo(model, {
|
|
69
111
|
isOpen: () => false,
|
|
112
|
+
transitionState: () => (model.isAnimated ? 'LeaveStart' : 'Idle'),
|
|
70
113
|
maybeActiveItemIndex: () => Option.none(),
|
|
71
114
|
activationTrigger: () => 'Keyboard',
|
|
72
115
|
searchQuery: () => '',
|
|
73
116
|
searchVersion: () => 0,
|
|
74
117
|
maybeLastPointerPosition: () => Option.none(),
|
|
118
|
+
maybeLastButtonPointerType: () => Option.none(),
|
|
119
|
+
maybePointerOrigin: () => Option.none(),
|
|
75
120
|
});
|
|
76
121
|
const buttonSelector = (id) => `#${id}-button`;
|
|
77
122
|
const itemsSelector = (id) => `#${id}-items`;
|
|
78
123
|
const itemSelector = (id, index) => `#${id}-item-${index}`;
|
|
79
124
|
/** Processes a menu message and returns the next model and commands. */
|
|
80
|
-
export const update = (model, message) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
125
|
+
export const update = (model, message) => {
|
|
126
|
+
const maybeNextFrameCommand = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
|
|
127
|
+
const maybeLockScrollCommand = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
|
|
128
|
+
const maybeUnlockScrollCommand = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
|
|
129
|
+
const maybeInertOthersCommand = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
|
|
130
|
+
buttonSelector(model.id),
|
|
131
|
+
itemsSelector(model.id),
|
|
132
|
+
]).pipe(Effect.as(NoOp())));
|
|
133
|
+
const maybeRestoreInertCommand = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
|
|
134
|
+
return M.value(message).pipe(M.withReturnType(), M.tagsExhaustive({
|
|
135
|
+
Opened: ({ maybeActiveItemIndex }) => {
|
|
136
|
+
const nextModel = evo(model, {
|
|
137
|
+
isOpen: () => true,
|
|
138
|
+
transitionState: () => (model.isAnimated ? 'EnterStart' : 'Idle'),
|
|
139
|
+
maybeActiveItemIndex: () => maybeActiveItemIndex,
|
|
140
|
+
activationTrigger: () => Option.match(maybeActiveItemIndex, {
|
|
141
|
+
onNone: () => 'Pointer',
|
|
142
|
+
onSome: () => 'Keyboard',
|
|
143
|
+
}),
|
|
144
|
+
searchQuery: () => '',
|
|
145
|
+
searchVersion: () => 0,
|
|
146
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
147
|
+
});
|
|
148
|
+
return [
|
|
149
|
+
nextModel,
|
|
150
|
+
pipe(Array.getSomes([
|
|
151
|
+
maybeNextFrameCommand,
|
|
152
|
+
maybeLockScrollCommand,
|
|
153
|
+
maybeInertOthersCommand,
|
|
154
|
+
]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
155
|
+
];
|
|
156
|
+
},
|
|
157
|
+
Closed: () => [
|
|
158
|
+
closedModel(model),
|
|
159
|
+
pipe(Array.getSomes([
|
|
160
|
+
maybeNextFrameCommand,
|
|
161
|
+
maybeUnlockScrollCommand,
|
|
162
|
+
maybeRestoreInertCommand,
|
|
163
|
+
]), Array.prepend(Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
164
|
+
],
|
|
165
|
+
ClosedByTab: () => [
|
|
166
|
+
closedModel(model),
|
|
167
|
+
Array.getSomes([
|
|
168
|
+
maybeNextFrameCommand,
|
|
169
|
+
maybeUnlockScrollCommand,
|
|
170
|
+
maybeRestoreInertCommand,
|
|
171
|
+
]),
|
|
172
|
+
],
|
|
173
|
+
ActivatedItem: ({ index, activationTrigger }) => [
|
|
115
174
|
evo(model, {
|
|
116
175
|
maybeActiveItemIndex: () => Option.some(index),
|
|
117
|
-
activationTrigger: () =>
|
|
118
|
-
maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
|
|
119
|
-
}),
|
|
120
|
-
[],
|
|
121
|
-
];
|
|
122
|
-
},
|
|
123
|
-
ItemDeactivated: () => model.activationTrigger === 'Pointer'
|
|
124
|
-
? [evo(model, { maybeActiveItemIndex: () => Option.none() }), []]
|
|
125
|
-
: [model, []],
|
|
126
|
-
ItemSelected: () => [
|
|
127
|
-
closedModel(model),
|
|
128
|
-
[Task.focus(buttonSelector(model.id), () => NoOp())],
|
|
129
|
-
],
|
|
130
|
-
Searched: ({ key, maybeTargetIndex }) => {
|
|
131
|
-
const nextSearchQuery = model.searchQuery + key;
|
|
132
|
-
const nextSearchVersion = model.searchVersion + 1;
|
|
133
|
-
return [
|
|
134
|
-
evo(model, {
|
|
135
|
-
searchQuery: () => nextSearchQuery,
|
|
136
|
-
searchVersion: () => nextSearchVersion,
|
|
137
|
-
maybeActiveItemIndex: () => Option.orElse(maybeTargetIndex, () => model.maybeActiveItemIndex),
|
|
176
|
+
activationTrigger: () => activationTrigger,
|
|
138
177
|
}),
|
|
178
|
+
activationTrigger === 'Keyboard'
|
|
179
|
+
? [
|
|
180
|
+
Task.scrollIntoView(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
|
|
181
|
+
]
|
|
182
|
+
: [],
|
|
183
|
+
],
|
|
184
|
+
MovedPointerOverItem: ({ index, screenX, screenY }) => {
|
|
185
|
+
const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
|
|
186
|
+
if (isSamePosition) {
|
|
187
|
+
return [model, []];
|
|
188
|
+
}
|
|
189
|
+
return [
|
|
190
|
+
evo(model, {
|
|
191
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
192
|
+
activationTrigger: () => 'Pointer',
|
|
193
|
+
maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
|
|
194
|
+
}),
|
|
195
|
+
[],
|
|
196
|
+
];
|
|
197
|
+
},
|
|
198
|
+
DeactivatedItem: () => model.activationTrigger === 'Pointer'
|
|
199
|
+
? [evo(model, { maybeActiveItemIndex: () => Option.none() }), []]
|
|
200
|
+
: [model, []],
|
|
201
|
+
SelectedItem: () => [
|
|
202
|
+
closedModel(model),
|
|
203
|
+
pipe(Array.getSomes([
|
|
204
|
+
maybeNextFrameCommand,
|
|
205
|
+
maybeUnlockScrollCommand,
|
|
206
|
+
maybeRestoreInertCommand,
|
|
207
|
+
]), Array.prepend(Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
208
|
+
],
|
|
209
|
+
RequestedItemClick: ({ index }) => [
|
|
210
|
+
model,
|
|
139
211
|
[
|
|
140
|
-
Task.
|
|
212
|
+
Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
|
|
141
213
|
],
|
|
142
|
-
]
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
return [
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
})
|
|
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),
|
|
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 [evo(model, { searchQuery: () => '' }), []];
|
|
234
|
+
},
|
|
235
|
+
AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(M.withReturnType(), 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
|
+
Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
|
|
244
|
+
],
|
|
245
|
+
]), M.orElse(() => [model, []])),
|
|
246
|
+
EndedTransition: () => M.value(model.transitionState).pipe(M.withReturnType(), M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
|
|
247
|
+
evo(model, { transitionState: () => 'Idle' }),
|
|
248
|
+
[],
|
|
249
|
+
]), M.orElse(() => [model, []])),
|
|
250
|
+
PressedPointerOnButton: ({ pointerType, button, screenX, screenY, timeStamp, }) => {
|
|
251
|
+
const withPointerType = evo(model, {
|
|
252
|
+
maybeLastButtonPointerType: () => Option.some(pointerType),
|
|
253
|
+
});
|
|
254
|
+
if (pointerType !== 'mouse' || button !== 0) {
|
|
255
|
+
return [withPointerType, []];
|
|
256
|
+
}
|
|
257
|
+
if (model.isOpen) {
|
|
258
|
+
return [
|
|
259
|
+
closedModel(withPointerType),
|
|
260
|
+
pipe(Array.getSomes([
|
|
261
|
+
maybeNextFrameCommand,
|
|
262
|
+
maybeUnlockScrollCommand,
|
|
263
|
+
maybeRestoreInertCommand,
|
|
264
|
+
]), Array.prepend(Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
265
|
+
];
|
|
266
|
+
}
|
|
267
|
+
const nextModel = evo(withPointerType, {
|
|
268
|
+
isOpen: () => true,
|
|
269
|
+
transitionState: () => (model.isAnimated ? 'EnterStart' : 'Idle'),
|
|
270
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
271
|
+
activationTrigger: () => 'Pointer',
|
|
272
|
+
searchQuery: () => '',
|
|
273
|
+
searchVersion: () => 0,
|
|
274
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
275
|
+
maybePointerOrigin: () => Option.some({ screenX, screenY, timeStamp }),
|
|
276
|
+
});
|
|
277
|
+
return [
|
|
278
|
+
nextModel,
|
|
279
|
+
pipe(Array.getSomes([
|
|
280
|
+
maybeNextFrameCommand,
|
|
281
|
+
maybeLockScrollCommand,
|
|
282
|
+
maybeInertOthersCommand,
|
|
283
|
+
]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
284
|
+
];
|
|
285
|
+
},
|
|
286
|
+
ReleasedPointerOnItems: ({ screenX, screenY, timeStamp }) => {
|
|
287
|
+
const hasNoOrigin = Option.isNone(model.maybePointerOrigin);
|
|
288
|
+
const hasNoActiveItem = Option.isNone(model.maybeActiveItemIndex);
|
|
289
|
+
const isMovementBelowThreshold = Option.exists(model.maybePointerOrigin, origin => Math.abs(screenX - origin.screenX) <
|
|
290
|
+
POINTER_MOVEMENT_THRESHOLD_PIXELS &&
|
|
291
|
+
Math.abs(screenY - origin.screenY) <
|
|
292
|
+
POINTER_MOVEMENT_THRESHOLD_PIXELS);
|
|
293
|
+
const isHoldTimeBelowThreshold = Option.exists(model.maybePointerOrigin, origin => timeStamp - origin.timeStamp < POINTER_HOLD_THRESHOLD_MILLISECONDS);
|
|
294
|
+
if (hasNoOrigin ||
|
|
295
|
+
isMovementBelowThreshold ||
|
|
296
|
+
isHoldTimeBelowThreshold ||
|
|
297
|
+
hasNoActiveItem) {
|
|
298
|
+
return [model, []];
|
|
299
|
+
}
|
|
300
|
+
return [
|
|
301
|
+
model,
|
|
302
|
+
[
|
|
303
|
+
Task.clickElement(itemSelector(model.id, model.maybeActiveItemIndex.value)).pipe(Effect.ignore, Effect.as(NoOp())),
|
|
304
|
+
],
|
|
305
|
+
];
|
|
306
|
+
},
|
|
307
|
+
NoOp: () => [model, []],
|
|
308
|
+
}));
|
|
309
|
+
};
|
|
152
310
|
export const groupContiguous = (items, toKey) => {
|
|
153
311
|
const tagged = Array.map(items, (item, index) => ({
|
|
154
312
|
key: toKey(item, index),
|
|
155
313
|
item,
|
|
156
314
|
}));
|
|
157
|
-
return Array.chop(tagged,
|
|
315
|
+
return Array.chop(tagged, nonEmpty => {
|
|
158
316
|
const key = Array.headNonEmpty(nonEmpty).key;
|
|
159
|
-
const [matching, rest] = Array.span(nonEmpty,
|
|
317
|
+
const [matching, rest] = Array.span(nonEmpty, tagged => tagged.key === key);
|
|
160
318
|
return [{ key, items: Array.map(matching, ({ item }) => item) }, rest];
|
|
161
319
|
});
|
|
162
320
|
};
|
|
@@ -167,18 +325,35 @@ export const resolveTypeaheadMatch = (items, query, maybeActiveItemIndex, isDisa
|
|
|
167
325
|
const offset = isRefinement ? 0 : 1;
|
|
168
326
|
const startIndex = Option.match(maybeActiveItemIndex, {
|
|
169
327
|
onNone: () => 0,
|
|
170
|
-
onSome:
|
|
328
|
+
onSome: index => index + offset,
|
|
171
329
|
});
|
|
172
330
|
const isEnabledMatch = (index) => !isDisabled(index) &&
|
|
173
|
-
pipe(items, Array.get(index), Option.exists(
|
|
174
|
-
return pipe(items.length, Array.makeBy(
|
|
331
|
+
pipe(items, Array.get(index), Option.exists(item => pipe(itemToSearchText(item, index), Str.toLowerCase, Str.startsWith(lowerQuery))));
|
|
332
|
+
return pipe(items.length, Array.makeBy(step => wrapIndex(startIndex + step, items.length)), Array.findFirst(isEnabledMatch));
|
|
175
333
|
};
|
|
176
334
|
/** Renders a headless menu with typeahead search, keyboard navigation, and aria-activedescendant focus management. */
|
|
177
335
|
export const view = (config) => {
|
|
178
|
-
const { div, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, Class, DataAttribute, Id, OnBlur, OnClick, OnKeyDownPreventDefault,
|
|
179
|
-
const { model: { id, isOpen, maybeActiveItemIndex, searchQuery }, toMessage, items, itemToConfig, isItemDisabled, itemToSearchText = (item) => item, isButtonDisabled, buttonContent, buttonClassName, itemsClassName, backdropClassName, className, itemGroupKey, groupToHeading, groupClassName, separatorClassName, } = config;
|
|
336
|
+
const { div, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, Class, DataAttribute, Id, OnBlur, OnClick, OnKeyDownPreventDefault, OnKeyUpPreventDefault, OnPointerDown, OnPointerLeave, OnPointerMove, OnPointerUp, Role, Tabindex, Type, keyed, } = html();
|
|
337
|
+
const { model: { id, isOpen, transitionState, maybeActiveItemIndex, searchQuery, maybeLastButtonPointerType, }, toMessage, items, itemToConfig, isItemDisabled, itemToSearchText = (item) => item, isButtonDisabled, buttonContent, buttonClassName, itemsClassName, backdropClassName, className, itemGroupKey, groupToHeading, groupClassName, separatorClassName, } = config;
|
|
338
|
+
const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
|
|
339
|
+
const isVisible = isOpen || isLeaving;
|
|
340
|
+
const transitionAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
|
|
341
|
+
DataAttribute('closed', ''),
|
|
342
|
+
DataAttribute('enter', ''),
|
|
343
|
+
DataAttribute('transition', ''),
|
|
344
|
+
]), M.when('EnterAnimating', () => [
|
|
345
|
+
DataAttribute('enter', ''),
|
|
346
|
+
DataAttribute('transition', ''),
|
|
347
|
+
]), M.when('LeaveStart', () => [
|
|
348
|
+
DataAttribute('leave', ''),
|
|
349
|
+
DataAttribute('transition', ''),
|
|
350
|
+
]), M.when('LeaveAnimating', () => [
|
|
351
|
+
DataAttribute('closed', ''),
|
|
352
|
+
DataAttribute('leave', ''),
|
|
353
|
+
DataAttribute('transition', ''),
|
|
354
|
+
]), M.orElse(() => []));
|
|
180
355
|
const isDisabled = (index) => !!isItemDisabled &&
|
|
181
|
-
pipe(items, Array.get(index), Option.exists(
|
|
356
|
+
pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
|
|
182
357
|
const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(0, 1);
|
|
183
358
|
const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(items.length - 1, -1);
|
|
184
359
|
const handleButtonKeyDown = (key) => M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(toMessage(Opened({
|
|
@@ -186,36 +361,59 @@ export const view = (config) => {
|
|
|
186
361
|
})))), M.when('ArrowUp', () => Option.some(toMessage(Opened({
|
|
187
362
|
maybeActiveItemIndex: Option.some(lastEnabledIndex),
|
|
188
363
|
})))), M.orElse(() => Option.none()));
|
|
189
|
-
const
|
|
190
|
-
|
|
191
|
-
|
|
364
|
+
const handleButtonPointerDown = (pointerType, button, screenX, screenY, timeStamp) => Option.some(toMessage(PressedPointerOnButton({
|
|
365
|
+
pointerType,
|
|
366
|
+
button,
|
|
367
|
+
screenX,
|
|
368
|
+
screenY,
|
|
369
|
+
timeStamp,
|
|
370
|
+
})));
|
|
371
|
+
const handleButtonClick = () => {
|
|
372
|
+
const isMouse = Option.exists(maybeLastButtonPointerType, type => type === 'mouse');
|
|
373
|
+
if (isMouse) {
|
|
374
|
+
return toMessage(NoOp());
|
|
375
|
+
}
|
|
376
|
+
else if (isOpen) {
|
|
377
|
+
return toMessage(Closed());
|
|
378
|
+
}
|
|
379
|
+
else {
|
|
380
|
+
return toMessage(Opened({ maybeActiveItemIndex: Option.none() }));
|
|
381
|
+
}
|
|
382
|
+
};
|
|
383
|
+
const handleSpaceKeyUp = (key) => OptionExt.when(key === ' ', toMessage(NoOp()));
|
|
192
384
|
const resolveActiveIndex = keyToIndex('ArrowDown', 'ArrowUp', items.length, Option.getOrElse(maybeActiveItemIndex, () => 0), isDisabled);
|
|
193
|
-
const
|
|
194
|
-
index: resolveActiveIndex(key),
|
|
195
|
-
activationTrigger: 'Keyboard',
|
|
196
|
-
})))), M.when((key) => key.length === 1, () => {
|
|
385
|
+
const searchForKey = (key) => {
|
|
197
386
|
const nextQuery = searchQuery + key;
|
|
198
387
|
const maybeTargetIndex = resolveTypeaheadMatch(items, nextQuery, maybeActiveItemIndex, isDisabled, itemToSearchText, Str.isNonEmpty(searchQuery));
|
|
199
388
|
return Option.some(toMessage(Searched({ key, maybeTargetIndex })));
|
|
200
|
-
}
|
|
389
|
+
};
|
|
390
|
+
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)
|
|
391
|
+
? searchForKey(' ')
|
|
392
|
+
: Option.map(maybeActiveItemIndex, index => toMessage(RequestedItemClick({ index })))), M.whenOr('ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(toMessage(ActivatedItem({
|
|
393
|
+
index: resolveActiveIndex(key),
|
|
394
|
+
activationTrigger: 'Keyboard',
|
|
395
|
+
})))), M.when(key => key.length === 1, () => searchForKey(key)), M.orElse(() => Option.none()));
|
|
396
|
+
const handleItemsPointerUp = (screenX, screenY, pointerType, timeStamp) => OptionExt.when(pointerType === 'mouse', toMessage(ReleasedPointerOnItems({ screenX, screenY, timeStamp })));
|
|
201
397
|
const buttonAttributes = [
|
|
202
398
|
Id(`${id}-button`),
|
|
203
399
|
Type('button'),
|
|
204
400
|
Class(buttonClassName),
|
|
205
401
|
AriaHasPopup('menu'),
|
|
206
|
-
AriaExpanded(
|
|
402
|
+
AriaExpanded(isVisible),
|
|
207
403
|
AriaControls(`${id}-items`),
|
|
208
404
|
...(isButtonDisabled
|
|
209
405
|
? [AriaDisabled(true), DataAttribute('disabled', '')]
|
|
210
406
|
: [
|
|
407
|
+
OnPointerDown(handleButtonPointerDown),
|
|
211
408
|
OnKeyDownPreventDefault(handleButtonKeyDown),
|
|
409
|
+
OnKeyUpPreventDefault(handleSpaceKeyUp),
|
|
212
410
|
OnClick(handleButtonClick()),
|
|
213
411
|
]),
|
|
214
|
-
...(
|
|
412
|
+
...(isVisible ? [DataAttribute('open', '')] : []),
|
|
215
413
|
];
|
|
216
414
|
const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
|
|
217
415
|
onNone: () => [],
|
|
218
|
-
onSome:
|
|
416
|
+
onSome: index => [AriaActiveDescendant(itemId(id, index))],
|
|
219
417
|
});
|
|
220
418
|
const itemsContainerAttributes = [
|
|
221
419
|
Id(`${id}-items`),
|
|
@@ -224,16 +422,24 @@ export const view = (config) => {
|
|
|
224
422
|
...maybeActiveDescendant,
|
|
225
423
|
Tabindex(0),
|
|
226
424
|
Class(itemsClassName),
|
|
227
|
-
|
|
228
|
-
|
|
425
|
+
...transitionAttributes,
|
|
426
|
+
...(isLeaving
|
|
427
|
+
? []
|
|
428
|
+
: [
|
|
429
|
+
OnKeyDownPreventDefault(handleItemsKeyDown),
|
|
430
|
+
OnKeyUpPreventDefault(handleSpaceKeyUp),
|
|
431
|
+
OnPointerUp(handleItemsPointerUp),
|
|
432
|
+
OnBlur(toMessage(ClosedByTab())),
|
|
433
|
+
]),
|
|
229
434
|
];
|
|
230
435
|
const menuItems = Array.map(items, (item, index) => {
|
|
231
|
-
const isActiveItem = Option.exists(maybeActiveItemIndex,
|
|
436
|
+
const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
|
|
232
437
|
const isDisabledItem = isDisabled(index);
|
|
233
438
|
const itemConfig = itemToConfig(item, {
|
|
234
439
|
isActive: isActiveItem,
|
|
235
440
|
isDisabled: isDisabledItem,
|
|
236
441
|
});
|
|
442
|
+
const isInteractive = !isDisabledItem && !isLeaving;
|
|
237
443
|
return keyed('div')(itemId(id, index), [
|
|
238
444
|
Id(itemId(id, index)),
|
|
239
445
|
Role('menuitem'),
|
|
@@ -242,11 +448,14 @@ export const view = (config) => {
|
|
|
242
448
|
...(isActiveItem ? [DataAttribute('active', '')] : []),
|
|
243
449
|
...(isDisabledItem
|
|
244
450
|
? [AriaDisabled(true), DataAttribute('disabled', '')]
|
|
245
|
-
: [
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
451
|
+
: []),
|
|
452
|
+
...(isInteractive
|
|
453
|
+
? [
|
|
454
|
+
OnClick(toMessage(SelectedItem({ index }))),
|
|
455
|
+
OnPointerMove((screenX, screenY, pointerType) => OptionExt.when(pointerType !== 'touch', toMessage(MovedPointerOverItem({ index, screenX, screenY })))),
|
|
456
|
+
OnPointerLeave(pointerType => OptionExt.when(pointerType !== 'touch', toMessage(DeactivatedItem()))),
|
|
457
|
+
]
|
|
458
|
+
: []),
|
|
250
459
|
], [itemConfig.content]);
|
|
251
460
|
});
|
|
252
461
|
const renderGroupedItems = () => {
|
|
@@ -255,14 +464,14 @@ export const view = (config) => {
|
|
|
255
464
|
}
|
|
256
465
|
const segments = groupContiguous(menuItems, (_, index) => Array.get(items, index).pipe(Option.match({
|
|
257
466
|
onNone: () => '',
|
|
258
|
-
onSome:
|
|
467
|
+
onSome: item => itemGroupKey(item, index),
|
|
259
468
|
})));
|
|
260
469
|
return Array.flatMap(segments, (segment, segmentIndex) => {
|
|
261
470
|
const maybeHeading = Option.fromNullable(groupToHeading && groupToHeading(segment.key));
|
|
262
471
|
const headingId = `${id}-heading-${segment.key}`;
|
|
263
472
|
const headingElement = Option.match(maybeHeading, {
|
|
264
473
|
onNone: () => [],
|
|
265
|
-
onSome:
|
|
474
|
+
onSome: heading => [
|
|
266
475
|
keyed('div')(headingId, [Id(headingId), Role('presentation'), Class(heading.className)], [heading.content]),
|
|
267
476
|
],
|
|
268
477
|
});
|
|
@@ -280,18 +489,21 @@ export const view = (config) => {
|
|
|
280
489
|
return [...separator, groupElement];
|
|
281
490
|
});
|
|
282
491
|
};
|
|
283
|
-
const backdrop = keyed('div')(`${id}-backdrop`, [
|
|
492
|
+
const backdrop = keyed('div')(`${id}-backdrop`, [
|
|
493
|
+
Class(backdropClassName),
|
|
494
|
+
...(isLeaving ? [] : [OnClick(toMessage(Closed()))]),
|
|
495
|
+
], []);
|
|
284
496
|
const renderedItems = renderGroupedItems();
|
|
285
|
-
const
|
|
497
|
+
const visibleContent = [
|
|
286
498
|
backdrop,
|
|
287
499
|
keyed('div')(`${id}-items-container`, itemsContainerAttributes, renderedItems),
|
|
288
500
|
];
|
|
289
501
|
const wrapperAttributes = [
|
|
290
502
|
...(className ? [Class(className)] : []),
|
|
291
|
-
...(
|
|
503
|
+
...(isVisible ? [DataAttribute('open', '')] : []),
|
|
292
504
|
];
|
|
293
505
|
return div(wrapperAttributes, [
|
|
294
506
|
keyed('button')(`${id}-button`, buttonAttributes, [buttonContent]),
|
|
295
|
-
...(
|
|
507
|
+
...(isVisible ? visibleContent : []),
|
|
296
508
|
]);
|
|
297
509
|
};
|
package/dist/ui/menu/public.d.ts
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export { init, update, view, Model, Message } from './index';
|
|
2
|
-
export type { ActivationTrigger, Opened, Closed, ClosedByTab,
|
|
1
|
+
export { init, update, view, Model, Message, TransitionState } from './index';
|
|
2
|
+
export type { ActivationTrigger, Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, InitConfig, ViewConfig, ItemConfig, GroupHeading, } from './index';
|
|
3
3
|
//# sourceMappingURL=public.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/menu/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;
|
|
1
|
+
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/menu/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,eAAe,EAAE,MAAM,SAAS,CAAA;AAE7E,YAAY,EACV,iBAAiB,EACjB,MAAM,EACN,MAAM,EACN,WAAW,EACX,aAAa,EACb,eAAe,EACf,YAAY,EACZ,oBAAoB,EACpB,QAAQ,EACR,aAAa,EACb,IAAI,EACJ,uBAAuB,EACvB,eAAe,EACf,UAAU,EACV,UAAU,EACV,UAAU,EACV,YAAY,GACb,MAAM,SAAS,CAAA"}
|
package/dist/ui/menu/public.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { init, update, view, Model, Message } from './index';
|
|
1
|
+
export { init, update, view, Model, Message, TransitionState } from './index';
|