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.
Files changed (85) hide show
  1. package/README.md +14 -14
  2. package/dist/command/index.d.ts +4 -0
  3. package/dist/command/index.d.ts.map +1 -0
  4. package/dist/command/index.js +1 -0
  5. package/dist/command/public.d.ts +2 -0
  6. package/dist/command/public.d.ts.map +1 -0
  7. package/dist/command/public.js +1 -0
  8. package/dist/effectExtensions/optionExtensions.d.ts +4 -0
  9. package/dist/effectExtensions/optionExtensions.d.ts.map +1 -1
  10. package/dist/effectExtensions/optionExtensions.js +2 -1
  11. package/dist/fieldValidation/index.d.ts.map +1 -1
  12. package/dist/fieldValidation/index.js +4 -4
  13. package/dist/html/index.d.ts +45 -3
  14. package/dist/html/index.d.ts.map +1 -1
  15. package/dist/html/index.js +53 -7
  16. package/dist/index.d.ts +2 -0
  17. package/dist/index.d.ts.map +1 -1
  18. package/dist/index.js +1 -0
  19. package/dist/message/index.d.ts +2 -0
  20. package/dist/message/index.d.ts.map +1 -0
  21. package/dist/message/index.js +1 -0
  22. package/dist/message/public.d.ts +2 -0
  23. package/dist/message/public.d.ts.map +1 -0
  24. package/dist/message/public.js +1 -0
  25. package/dist/route/index.d.ts +1 -0
  26. package/dist/route/index.d.ts.map +1 -1
  27. package/dist/route/index.js +1 -0
  28. package/dist/route/parser.js +17 -17
  29. package/dist/route/public.d.ts +1 -1
  30. package/dist/route/public.d.ts.map +1 -1
  31. package/dist/route/public.js +1 -1
  32. package/dist/runtime/public.d.ts +1 -1
  33. package/dist/runtime/public.d.ts.map +1 -1
  34. package/dist/runtime/runtime.d.ts +2 -3
  35. package/dist/runtime/runtime.d.ts.map +1 -1
  36. package/dist/runtime/runtime.js +9 -9
  37. package/dist/schema/index.d.ts +36 -8
  38. package/dist/schema/index.d.ts.map +1 -1
  39. package/dist/schema/index.js +6 -0
  40. package/dist/task/dom.d.ts +58 -0
  41. package/dist/task/dom.d.ts.map +1 -0
  42. package/dist/task/dom.js +112 -0
  43. package/dist/task/error.d.ts +18 -0
  44. package/dist/task/error.d.ts.map +1 -0
  45. package/dist/task/error.js +7 -0
  46. package/dist/task/index.d.ts +7 -108
  47. package/dist/task/index.d.ts.map +1 -1
  48. package/dist/task/index.js +7 -168
  49. package/dist/task/inert.d.ts +26 -0
  50. package/dist/task/inert.d.ts.map +1 -0
  51. package/dist/task/inert.js +87 -0
  52. package/dist/task/public.d.ts +1 -1
  53. package/dist/task/public.d.ts.map +1 -1
  54. package/dist/task/public.js +1 -1
  55. package/dist/task/random.d.ts +11 -0
  56. package/dist/task/random.d.ts.map +1 -0
  57. package/dist/task/random.js +10 -0
  58. package/dist/task/scrollLock.d.ts +24 -0
  59. package/dist/task/scrollLock.d.ts.map +1 -0
  60. package/dist/task/scrollLock.js +46 -0
  61. package/dist/task/time.d.ts +43 -0
  62. package/dist/task/time.d.ts.map +1 -0
  63. package/dist/task/time.js +53 -0
  64. package/dist/task/timing.d.ts +35 -0
  65. package/dist/task/timing.d.ts.map +1 -0
  66. package/dist/task/timing.js +51 -0
  67. package/dist/ui/dialog/index.d.ts +1 -1
  68. package/dist/ui/dialog/index.d.ts.map +1 -1
  69. package/dist/ui/dialog/index.js +7 -7
  70. package/dist/ui/disclosure/index.d.ts +1 -1
  71. package/dist/ui/disclosure/index.d.ts.map +1 -1
  72. package/dist/ui/disclosure/index.js +7 -7
  73. package/dist/ui/keyboard.d.ts.map +1 -1
  74. package/dist/ui/keyboard.js +1 -1
  75. package/dist/ui/menu/index.d.ts +71 -18
  76. package/dist/ui/menu/index.d.ts.map +1 -1
  77. package/dist/ui/menu/index.js +325 -113
  78. package/dist/ui/menu/public.d.ts +2 -2
  79. package/dist/ui/menu/public.d.ts.map +1 -1
  80. package/dist/ui/menu/public.js +1 -1
  81. package/dist/ui/tabs/index.d.ts +4 -5
  82. package/dist/ui/tabs/index.d.ts.map +1 -1
  83. package/dist/ui/tabs/index.js +12 -14
  84. package/dist/url/index.js +4 -4
  85. package/package.json +13 -1
@@ -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 { ts } from '../../schema';
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 = ts('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 = ts('Closed');
39
+ export const Closed = m('Closed');
27
40
  /** Sent when focus leaves the menu items container via Tab key. */
28
- export const ClosedByTab = ts('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 ItemActivated = ts('ItemActivated', {
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 ItemDeactivated = ts('ItemDeactivated');
48
+ export const DeactivatedItem = m('DeactivatedItem');
36
49
  /** Sent when an item is selected via Enter, Space, or click. */
37
- export const ItemSelected = ts('ItemSelected', { index: S.Number });
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 = ts('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 SearchCleared = ts('SearchCleared', { version: S.Number });
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 PointerMovedOverItem = ts('PointerMovedOverItem', {
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 = ts('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, ItemActivated, ItemDeactivated, ItemSelected, PointerMovedOverItem, Searched, SearchCleared, NoOp);
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) => M.value(message).pipe(M.withReturnType(), M.tagsExhaustive({
81
- Opened: ({ maybeActiveItemIndex }) => [
82
- evo(model, {
83
- isOpen: () => true,
84
- maybeActiveItemIndex: () => maybeActiveItemIndex,
85
- activationTrigger: () => Option.match(maybeActiveItemIndex, {
86
- onNone: () => 'Pointer',
87
- onSome: () => 'Keyboard',
88
- }),
89
- searchQuery: () => '',
90
- searchVersion: () => 0,
91
- maybeLastPointerPosition: () => Option.none(),
92
- }),
93
- [Task.focus(itemsSelector(model.id), () => NoOp())],
94
- ],
95
- Closed: () => [
96
- closedModel(model),
97
- [Task.focus(buttonSelector(model.id), () => NoOp())],
98
- ],
99
- ClosedByTab: () => [closedModel(model), []],
100
- ItemActivated: ({ index, activationTrigger }) => [
101
- evo(model, {
102
- maybeActiveItemIndex: () => Option.some(index),
103
- activationTrigger: () => activationTrigger,
104
- }),
105
- activationTrigger === 'Keyboard'
106
- ? [Task.scrollIntoView(itemSelector(model.id, index), () => NoOp())]
107
- : [],
108
- ],
109
- PointerMovedOverItem: ({ index, screenX, screenY }) => {
110
- const isSamePosition = Option.exists(model.maybeLastPointerPosition, (position) => position.screenX === screenX && position.screenY === screenY);
111
- if (isSamePosition) {
112
- return [model, []];
113
- }
114
- return [
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: () => 'Pointer',
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.delay(SEARCH_DEBOUNCE_MILLISECONDS, () => SearchCleared({ version: nextSearchVersion })),
212
+ Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
141
213
  ],
142
- ];
143
- },
144
- SearchCleared: ({ version }) => {
145
- if (version !== model.searchVersion) {
146
- return [model, []];
147
- }
148
- return [evo(model, { searchQuery: () => '' }), []];
149
- },
150
- NoOp: () => [model, []],
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, (nonEmpty) => {
315
+ return Array.chop(tagged, nonEmpty => {
158
316
  const key = Array.headNonEmpty(nonEmpty).key;
159
- const [matching, rest] = Array.span(nonEmpty, (tagged) => tagged.key === key);
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: (index) => index + offset,
328
+ onSome: index => index + offset,
171
329
  });
172
330
  const isEnabledMatch = (index) => !isDisabled(index) &&
173
- pipe(items, Array.get(index), Option.exists((item) => pipe(itemToSearchText(item, index), Str.toLowerCase, Str.startsWith(lowerQuery))));
174
- return pipe(items.length, Array.makeBy((step) => wrapIndex(startIndex + step, items.length)), Array.findFirst(isEnabledMatch));
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, OnMouseLeave, OnPointerMove, Role, Tabindex, Type, keyed, } = html();
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((item) => isItemDisabled(item, index)));
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 handleButtonClick = () => isOpen
190
- ? toMessage(Closed())
191
- : toMessage(Opened({ maybeActiveItemIndex: Option.none() }));
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 handleItemsKeyDown = (key) => M.value(key).pipe(M.when('Escape', () => Option.some(toMessage(Closed()))), M.whenOr('Enter', ' ', () => Option.map(maybeActiveItemIndex, (index) => toMessage(ItemSelected({ index })))), M.whenOr('ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(toMessage(ItemActivated({
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
- }), M.orElse(() => Option.none()));
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(isOpen),
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
- ...(isOpen ? [DataAttribute('open', '')] : []),
412
+ ...(isVisible ? [DataAttribute('open', '')] : []),
215
413
  ];
216
414
  const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
217
415
  onNone: () => [],
218
- onSome: (index) => [AriaActiveDescendant(itemId(id, index))],
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
- OnKeyDownPreventDefault(handleItemsKeyDown),
228
- OnBlur(toMessage(ClosedByTab())),
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, (activeIndex) => activeIndex === index);
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
- OnClick(toMessage(ItemSelected({ index }))),
247
- OnPointerMove((screenX, screenY) => toMessage(PointerMovedOverItem({ index, screenX, screenY }))),
248
- OnMouseLeave(toMessage(ItemDeactivated())),
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: (item) => itemGroupKey(item, index),
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: (heading) => [
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`, [Class(backdropClassName), OnClick(toMessage(Closed()))], []);
492
+ const backdrop = keyed('div')(`${id}-backdrop`, [
493
+ Class(backdropClassName),
494
+ ...(isLeaving ? [] : [OnClick(toMessage(Closed()))]),
495
+ ], []);
284
496
  const renderedItems = renderGroupedItems();
285
- const openContent = [
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
- ...(isOpen ? [DataAttribute('open', '')] : []),
503
+ ...(isVisible ? [DataAttribute('open', '')] : []),
292
504
  ];
293
505
  return div(wrapperAttributes, [
294
506
  keyed('button')(`${id}-button`, buttonAttributes, [buttonContent]),
295
- ...(isOpen ? openContent : []),
507
+ ...(isVisible ? visibleContent : []),
296
508
  ]);
297
509
  };
@@ -1,3 +1,3 @@
1
- export { init, update, view, Model, Message } from './index';
2
- export type { ActivationTrigger, Opened, Closed, ClosedByTab, ItemActivated, ItemDeactivated, ItemSelected, PointerMovedOverItem, Searched, SearchCleared, NoOp, InitConfig, ViewConfig, ItemConfig, GroupHeading, } from './index';
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;AAE5D,YAAY,EACV,iBAAiB,EACjB,MAAM,EACN,MAAM,EACN,WAAW,EACX,aAAa,EACb,eAAe,EACf,YAAY,EACZ,oBAAoB,EACpB,QAAQ,EACR,aAAa,EACb,IAAI,EACJ,UAAU,EACV,UAAU,EACV,UAAU,EACV,YAAY,GACb,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"}
@@ -1 +1 @@
1
- export { init, update, view, Model, Message } from './index';
1
+ export { init, update, view, Model, Message, TransitionState } from './index';