foldkit 0.20.0 → 0.22.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 (40) hide show
  1. package/README.md +2 -1
  2. package/dist/html/index.d.ts +90 -19
  3. package/dist/html/index.d.ts.map +1 -1
  4. package/dist/html/index.js +36 -6
  5. package/dist/html/public.d.ts +1 -1
  6. package/dist/html/public.d.ts.map +1 -1
  7. package/dist/index.d.ts +1 -1
  8. package/dist/index.d.ts.map +1 -1
  9. package/dist/index.js +1 -0
  10. package/dist/runtime/public.d.ts +2 -2
  11. package/dist/runtime/public.d.ts.map +1 -1
  12. package/dist/runtime/public.js +1 -1
  13. package/dist/runtime/runtime.d.ts +16 -16
  14. package/dist/runtime/runtime.d.ts.map +1 -1
  15. package/dist/runtime/runtime.js +8 -8
  16. package/dist/task/dom.d.ts +13 -0
  17. package/dist/task/dom.d.ts.map +1 -1
  18. package/dist/task/dom.js +38 -1
  19. package/dist/task/elementMovement.d.ts +21 -0
  20. package/dist/task/elementMovement.d.ts.map +1 -0
  21. package/dist/task/elementMovement.js +48 -0
  22. package/dist/task/index.d.ts +3 -1
  23. package/dist/task/index.d.ts.map +1 -1
  24. package/dist/task/index.js +2 -1
  25. package/dist/task/scrollLock.d.ts +3 -0
  26. package/dist/task/scrollLock.d.ts.map +1 -1
  27. package/dist/task/scrollLock.js +36 -0
  28. package/dist/ui/dialog/index.d.ts.map +1 -1
  29. package/dist/ui/dialog/index.js +4 -10
  30. package/dist/ui/disclosure/index.d.ts.map +1 -1
  31. package/dist/ui/disclosure/index.js +4 -7
  32. package/dist/ui/menu/anchor.d.ts +18 -0
  33. package/dist/ui/menu/anchor.d.ts.map +1 -0
  34. package/dist/ui/menu/anchor.js +72 -0
  35. package/dist/ui/menu/index.d.ts +11 -5
  36. package/dist/ui/menu/index.d.ts.map +1 -1
  37. package/dist/ui/menu/index.js +45 -38
  38. package/dist/ui/menu/public.d.ts +1 -0
  39. package/dist/ui/menu/public.d.ts.map +1 -1
  40. package/package.json +2 -1
@@ -5,6 +5,7 @@ import { m } from '../../message';
5
5
  import { evo } from '../../struct';
6
6
  import * as Task from '../../task';
7
7
  import { findFirstEnabledIndex, keyToIndex, wrapIndex } from '../keyboard';
8
+ import { anchorHooks } from './anchor';
8
9
  // MODEL
9
10
  /** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
10
11
  export const ActivationTrigger = S.Literal('Pointer', 'Keyboard');
@@ -37,7 +38,7 @@ export const Opened = m('Opened', {
37
38
  });
38
39
  /** Sent when the menu closes via Escape key or backdrop click. */
39
40
  export const Closed = m('Closed');
40
- /** Sent when focus leaves the menu items container via Tab key. */
41
+ /** Sent when focus leaves the menu items container via Tab key or blur. */
41
42
  export const ClosedByTab = m('ClosedByTab');
42
43
  /** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger. */
43
44
  export const ActivatedItem = m('ActivatedItem', {
@@ -71,6 +72,8 @@ export const NoOp = m('NoOp');
71
72
  export const AdvancedTransitionFrame = m('AdvancedTransitionFrame');
72
73
  /** Sent internally when all CSS transitions on the menu items container have completed. */
73
74
  export const EndedTransition = m('EndedTransition');
75
+ /** Sent internally when the menu button moves in the viewport during a leave transition, cancelling the animation. */
76
+ export const DetectedButtonMovement = m('DetectedButtonMovement');
74
77
  /** Sent when the user presses a pointer device on the menu button. Records pointer type and toggles for mouse. */
75
78
  export const PressedPointerOnButton = m('PressedPointerOnButton', {
76
79
  pointerType: S.String,
@@ -86,7 +89,7 @@ export const ReleasedPointerOnItems = m('ReleasedPointerOnItems', {
86
89
  timeStamp: S.Number,
87
90
  });
88
91
  /** Union of all messages the menu component can produce. */
89
- export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, PressedPointerOnButton, ReleasedPointerOnItems);
92
+ export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, DetectedButtonMovement, PressedPointerOnButton, ReleasedPointerOnItems);
90
93
  // INIT
91
94
  const SEARCH_DEBOUNCE_MILLISECONDS = 350;
92
95
  const POINTER_HOLD_THRESHOLD_MILLISECONDS = 200;
@@ -96,7 +99,7 @@ export const init = (config) => ({
96
99
  id: config.id,
97
100
  isOpen: false,
98
101
  isAnimated: config.isAnimated ?? false,
99
- isModal: config.isModal ?? true,
102
+ isModal: config.isModal ?? false,
100
103
  transitionState: 'Idle',
101
104
  maybeActiveItemIndex: Option.none(),
102
105
  activationTrigger: 'Keyboard',
@@ -121,17 +124,18 @@ const closedModel = (model) => evo(model, {
121
124
  const buttonSelector = (id) => `#${id}-button`;
122
125
  const itemsSelector = (id) => `#${id}-items`;
123
126
  const itemSelector = (id, index) => `#${id}-item-${index}`;
127
+ const withUpdateReturn = M.withReturnType();
124
128
  /** Processes a menu message and returns the next model and commands. */
125
129
  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
+ const maybeNextFrame = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
131
+ const maybeLockScroll = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
132
+ const maybeUnlockScroll = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
133
+ const maybeInertOthers = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
130
134
  buttonSelector(model.id),
131
135
  itemsSelector(model.id),
132
136
  ]).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({
137
+ const maybeRestoreInert = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
138
+ return M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
135
139
  Opened: ({ maybeActiveItemIndex }) => {
136
140
  const nextModel = evo(model, {
137
141
  isOpen: () => true,
@@ -147,28 +151,20 @@ export const update = (model, message) => {
147
151
  });
148
152
  return [
149
153
  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())))),
154
+ pipe(Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
155
155
  ];
156
156
  },
157
157
  Closed: () => [
158
158
  closedModel(model),
159
159
  pipe(Array.getSomes([
160
- maybeNextFrameCommand,
161
- maybeUnlockScrollCommand,
162
- maybeRestoreInertCommand,
160
+ maybeNextFrame,
161
+ maybeUnlockScroll,
162
+ maybeRestoreInert,
163
163
  ]), Array.prepend(Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
164
164
  ],
165
165
  ClosedByTab: () => [
166
166
  closedModel(model),
167
- Array.getSomes([
168
- maybeNextFrameCommand,
169
- maybeUnlockScrollCommand,
170
- maybeRestoreInertCommand,
171
- ]),
167
+ Array.getSomes([maybeNextFrame, maybeUnlockScroll, maybeRestoreInert]),
172
168
  ],
173
169
  ActivatedItem: ({ index, activationTrigger }) => [
174
170
  evo(model, {
@@ -201,9 +197,9 @@ export const update = (model, message) => {
201
197
  SelectedItem: () => [
202
198
  closedModel(model),
203
199
  pipe(Array.getSomes([
204
- maybeNextFrameCommand,
205
- maybeUnlockScrollCommand,
206
- maybeRestoreInertCommand,
200
+ maybeNextFrame,
201
+ maybeUnlockScroll,
202
+ maybeRestoreInert,
207
203
  ]), Array.prepend(Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
208
204
  ],
209
205
  RequestedItemClick: ({ index }) => [
@@ -232,7 +228,7 @@ export const update = (model, message) => {
232
228
  }
233
229
  return [evo(model, { searchQuery: () => '' }), []];
234
230
  },
235
- AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(M.withReturnType(), M.when('EnterStart', () => [
231
+ AdvancedTransitionFrame: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('EnterStart', () => [
236
232
  evo(model, { transitionState: () => 'EnterAnimating' }),
237
233
  [
238
234
  Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
@@ -240,10 +236,14 @@ export const update = (model, message) => {
240
236
  ]), M.when('LeaveStart', () => [
241
237
  evo(model, { transitionState: () => 'LeaveAnimating' }),
242
238
  [
243
- Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition())),
239
+ Effect.raceFirst(Task.detectElementMovement(buttonSelector(model.id)).pipe(Effect.as(DetectedButtonMovement())), Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition()))),
244
240
  ],
245
241
  ]), M.orElse(() => [model, []])),
246
- EndedTransition: () => M.value(model.transitionState).pipe(M.withReturnType(), M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
242
+ EndedTransition: () => M.value(model.transitionState).pipe(withUpdateReturn, M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
243
+ evo(model, { transitionState: () => 'Idle' }),
244
+ [],
245
+ ]), M.orElse(() => [model, []])),
246
+ DetectedButtonMovement: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('LeaveAnimating', () => [
247
247
  evo(model, { transitionState: () => 'Idle' }),
248
248
  [],
249
249
  ]), M.orElse(() => [model, []])),
@@ -258,9 +258,9 @@ export const update = (model, message) => {
258
258
  return [
259
259
  closedModel(withPointerType),
260
260
  pipe(Array.getSomes([
261
- maybeNextFrameCommand,
262
- maybeUnlockScrollCommand,
263
- maybeRestoreInertCommand,
261
+ maybeNextFrame,
262
+ maybeUnlockScroll,
263
+ maybeRestoreInert,
264
264
  ]), Array.prepend(Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
265
265
  ];
266
266
  }
@@ -276,11 +276,7 @@ export const update = (model, message) => {
276
276
  });
277
277
  return [
278
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())))),
279
+ pipe(Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
284
280
  ];
285
281
  },
286
282
  ReleasedPointerOnItems: ({ screenX, screenY, timeStamp }) => {
@@ -333,8 +329,8 @@ export const resolveTypeaheadMatch = (items, query, maybeActiveItemIndex, isDisa
333
329
  };
334
330
  /** Renders a headless menu with typeahead search, keyboard navigation, and aria-activedescendant focus management. */
335
331
  export const view = (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;
332
+ const { div, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, Class, DataAttribute, Id, OnBlur, OnClick, OnDestroy, OnInsert, OnKeyDownPreventDefault, OnKeyUpPreventDefault, OnPointerDown, OnPointerLeave, OnPointerMove, OnPointerUp, Role, Style, Tabindex, Type, keyed, } = html();
333
+ 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, anchor, } = config;
338
334
  const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
339
335
  const isVisible = isOpen || isLeaving;
340
336
  const transitionAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
@@ -415,6 +411,16 @@ export const view = (config) => {
415
411
  onNone: () => [],
416
412
  onSome: index => [AriaActiveDescendant(itemId(id, index))],
417
413
  });
414
+ const hooks = anchor
415
+ ? anchorHooks({ buttonId: `${id}-button`, anchor })
416
+ : undefined;
417
+ const anchorAttributes = hooks
418
+ ? [
419
+ Style({ position: 'absolute', margin: '0', visibility: 'hidden' }),
420
+ OnInsert(hooks.onInsert),
421
+ OnDestroy(hooks.onDestroy),
422
+ ]
423
+ : [];
418
424
  const itemsContainerAttributes = [
419
425
  Id(`${id}-items`),
420
426
  Role('menu'),
@@ -422,6 +428,7 @@ export const view = (config) => {
422
428
  ...maybeActiveDescendant,
423
429
  Tabindex(0),
424
430
  Class(itemsClassName),
431
+ ...anchorAttributes,
425
432
  ...transitionAttributes,
426
433
  ...(isLeaving
427
434
  ? []
@@ -1,3 +1,4 @@
1
1
  export { init, update, view, Model, Message, TransitionState } from './index';
2
2
  export type { ActivationTrigger, Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, InitConfig, ViewConfig, ItemConfig, GroupHeading, } from './index';
3
+ export type { AnchorConfig } from './anchor';
3
4
  //# 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,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
+ {"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;AAEhB,YAAY,EAAE,YAAY,EAAE,MAAM,UAAU,CAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "foldkit",
3
- "version": "0.20.0",
3
+ "version": "0.22.0",
4
4
  "description": "Elm-inspired UI framework powered by Effect",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -106,6 +106,7 @@
106
106
  "directory": "packages/foldkit"
107
107
  },
108
108
  "dependencies": {
109
+ "@floating-ui/dom": "^1.7.5",
109
110
  "snabbdom": "^3.6.3"
110
111
  },
111
112
  "publishConfig": {