@foldkit/ui 0.112.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/LICENSE +21 -0
- package/README.md +67 -0
- package/dist/anchor.d.ts +38 -0
- package/dist/anchor.d.ts.map +1 -0
- package/dist/anchor.js +142 -0
- package/dist/animation/index.d.ts +49 -0
- package/dist/animation/index.d.ts.map +1 -0
- package/dist/animation/index.js +75 -0
- package/dist/animation/public.d.ts +3 -0
- package/dist/animation/public.d.ts.map +1 -0
- package/dist/animation/public.js +1 -0
- package/dist/animation/schema.d.ts +43 -0
- package/dist/animation/schema.d.ts.map +1 -0
- package/dist/animation/schema.js +41 -0
- package/dist/animation/update.d.ts +24 -0
- package/dist/animation/update.d.ts.map +1 -0
- package/dist/animation/update.js +67 -0
- package/dist/button/index.d.ts +17 -0
- package/dist/button/index.d.ts.map +1 -0
- package/dist/button/index.js +22 -0
- package/dist/button/public.d.ts +3 -0
- package/dist/button/public.d.ts.map +1 -0
- package/dist/button/public.js +1 -0
- package/dist/calendar/index.d.ts +462 -0
- package/dist/calendar/index.d.ts.map +1 -0
- package/dist/calendar/index.js +825 -0
- package/dist/calendar/public.d.ts +3 -0
- package/dist/calendar/public.d.ts.map +1 -0
- package/dist/calendar/public.js +1 -0
- package/dist/checkbox/index.d.ts +119 -0
- package/dist/checkbox/index.d.ts.map +1 -0
- package/dist/checkbox/index.js +111 -0
- package/dist/checkbox/public.d.ts +3 -0
- package/dist/checkbox/public.d.ts.map +1 -0
- package/dist/checkbox/public.js +1 -0
- package/dist/combobox/multi.d.ts +183 -0
- package/dist/combobox/multi.d.ts.map +1 -0
- package/dist/combobox/multi.js +81 -0
- package/dist/combobox/multiPublic.d.ts +3 -0
- package/dist/combobox/multiPublic.d.ts.map +1 -0
- package/dist/combobox/multiPublic.js +1 -0
- package/dist/combobox/public.d.ts +7 -0
- package/dist/combobox/public.d.ts.map +1 -0
- package/dist/combobox/public.js +3 -0
- package/dist/combobox/shared.d.ts +423 -0
- package/dist/combobox/shared.d.ts.map +1 -0
- package/dist/combobox/shared.js +708 -0
- package/dist/combobox/single.d.ts +198 -0
- package/dist/combobox/single.d.ts.map +1 -0
- package/dist/combobox/single.js +106 -0
- package/dist/datePicker/index.d.ts +457 -0
- package/dist/datePicker/index.d.ts.map +1 -0
- package/dist/datePicker/index.js +318 -0
- package/dist/datePicker/public.d.ts +3 -0
- package/dist/datePicker/public.d.ts.map +1 -0
- package/dist/datePicker/public.js +1 -0
- package/dist/dialog/index.d.ts +160 -0
- package/dist/dialog/index.d.ts.map +1 -0
- package/dist/dialog/index.js +211 -0
- package/dist/dialog/public.d.ts +3 -0
- package/dist/dialog/public.d.ts.map +1 -0
- package/dist/dialog/public.js +1 -0
- package/dist/disclosure/index.d.ts +110 -0
- package/dist/disclosure/index.d.ts.map +1 -0
- package/dist/disclosure/index.js +111 -0
- package/dist/disclosure/public.d.ts +3 -0
- package/dist/disclosure/public.d.ts.map +1 -0
- package/dist/disclosure/public.js +1 -0
- package/dist/dragAndDrop/index.d.ts +540 -0
- package/dist/dragAndDrop/index.d.ts.map +1 -0
- package/dist/dragAndDrop/index.js +535 -0
- package/dist/dragAndDrop/public.d.ts +3 -0
- package/dist/dragAndDrop/public.d.ts.map +1 -0
- package/dist/dragAndDrop/public.js +1 -0
- package/dist/fieldset/index.d.ts +21 -0
- package/dist/fieldset/index.d.ts.map +1 -0
- package/dist/fieldset/index.js +25 -0
- package/dist/fieldset/public.d.ts +3 -0
- package/dist/fieldset/public.d.ts.map +1 -0
- package/dist/fieldset/public.js +1 -0
- package/dist/fileDrop/index.d.ts +109 -0
- package/dist/fileDrop/index.d.ts.map +1 -0
- package/dist/fileDrop/index.js +127 -0
- package/dist/fileDrop/public.d.ts +3 -0
- package/dist/fileDrop/public.d.ts.map +1 -0
- package/dist/fileDrop/public.js +1 -0
- package/dist/group.d.ts +8 -0
- package/dist/group.d.ts.map +1 -0
- package/dist/group.js +13 -0
- package/dist/index.d.ts +25 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/input/index.d.ts +26 -0
- package/dist/input/index.d.ts.map +1 -0
- package/dist/input/index.js +43 -0
- package/dist/input/public.d.ts +3 -0
- package/dist/input/public.d.ts.map +1 -0
- package/dist/input/public.js +1 -0
- package/dist/internal/optionExtensions.d.ts +6 -0
- package/dist/internal/optionExtensions.d.ts.map +1 -0
- package/dist/internal/optionExtensions.js +2 -0
- package/dist/keyboard.d.ts +6 -0
- package/dist/keyboard.d.ts.map +1 -0
- package/dist/keyboard.js +9 -0
- package/dist/listbox/multi.d.ts +189 -0
- package/dist/listbox/multi.d.ts.map +1 -0
- package/dist/listbox/multi.js +65 -0
- package/dist/listbox/multiPublic.d.ts +3 -0
- package/dist/listbox/multiPublic.d.ts.map +1 -0
- package/dist/listbox/multiPublic.js +1 -0
- package/dist/listbox/public.d.ts +7 -0
- package/dist/listbox/public.d.ts.map +1 -0
- package/dist/listbox/public.js +3 -0
- package/dist/listbox/shared.d.ts +432 -0
- package/dist/listbox/shared.d.ts.map +1 -0
- package/dist/listbox/shared.js +670 -0
- package/dist/listbox/single.d.ts +207 -0
- package/dist/listbox/single.d.ts.map +1 -0
- package/dist/listbox/single.js +73 -0
- package/dist/menu/index.d.ts +368 -0
- package/dist/menu/index.d.ts.map +1 -0
- package/dist/menu/index.js +682 -0
- package/dist/menu/public.d.ts +4 -0
- package/dist/menu/public.d.ts.map +1 -0
- package/dist/menu/public.js +1 -0
- package/dist/popover/index.d.ts +267 -0
- package/dist/popover/index.d.ts.map +1 -0
- package/dist/popover/index.js +346 -0
- package/dist/popover/public.d.ts +4 -0
- package/dist/popover/public.d.ts.map +1 -0
- package/dist/popover/public.js +1 -0
- package/dist/radioGroup/index.d.ts +169 -0
- package/dist/radioGroup/index.d.ts.map +1 -0
- package/dist/radioGroup/index.js +197 -0
- package/dist/radioGroup/public.d.ts +3 -0
- package/dist/radioGroup/public.d.ts.map +1 -0
- package/dist/radioGroup/public.js +1 -0
- package/dist/select/index.d.ts +24 -0
- package/dist/select/index.d.ts.map +1 -0
- package/dist/select/index.js +40 -0
- package/dist/select/public.d.ts +3 -0
- package/dist/select/public.d.ts.map +1 -0
- package/dist/select/public.js +1 -0
- package/dist/slider/index.d.ts +318 -0
- package/dist/slider/index.d.ts.map +1 -0
- package/dist/slider/index.js +337 -0
- package/dist/slider/public.d.ts +3 -0
- package/dist/slider/public.d.ts.map +1 -0
- package/dist/slider/public.js +1 -0
- package/dist/switch/index.d.ts +99 -0
- package/dist/switch/index.d.ts.map +1 -0
- package/dist/switch/index.js +107 -0
- package/dist/switch/public.d.ts +3 -0
- package/dist/switch/public.d.ts.map +1 -0
- package/dist/switch/public.js +1 -0
- package/dist/tabs/index.d.ts +155 -0
- package/dist/tabs/index.d.ts.map +1 -0
- package/dist/tabs/index.js +185 -0
- package/dist/tabs/public.d.ts +3 -0
- package/dist/tabs/public.d.ts.map +1 -0
- package/dist/tabs/public.js +1 -0
- package/dist/test/apps/disabledButton.d.ts +38 -0
- package/dist/test/apps/disabledButton.d.ts.map +1 -0
- package/dist/test/apps/disabledButton.js +71 -0
- package/dist/textarea/index.d.ts +26 -0
- package/dist/textarea/index.d.ts.map +1 -0
- package/dist/textarea/index.js +44 -0
- package/dist/textarea/public.d.ts +3 -0
- package/dist/textarea/public.d.ts.map +1 -0
- package/dist/textarea/public.js +1 -0
- package/dist/toast/index.d.ts +608 -0
- package/dist/toast/index.d.ts.map +1 -0
- package/dist/toast/index.js +146 -0
- package/dist/toast/public.d.ts +4 -0
- package/dist/toast/public.d.ts.map +1 -0
- package/dist/toast/public.js +1 -0
- package/dist/toast/schema.d.ts +154 -0
- package/dist/toast/schema.d.ts.map +1 -0
- package/dist/toast/schema.js +93 -0
- package/dist/toast/update.d.ts +510 -0
- package/dist/toast/update.d.ts.map +1 -0
- package/dist/toast/update.js +225 -0
- package/dist/tooltip/index.d.ts +170 -0
- package/dist/tooltip/index.d.ts.map +1 -0
- package/dist/tooltip/index.js +253 -0
- package/dist/tooltip/public.d.ts +4 -0
- package/dist/tooltip/public.d.ts.map +1 -0
- package/dist/tooltip/public.js +1 -0
- package/dist/typeahead.d.ts +4 -0
- package/dist/typeahead.d.ts.map +1 -0
- package/dist/typeahead.js +14 -0
- package/dist/virtualList/index.d.ts +203 -0
- package/dist/virtualList/index.d.ts.map +1 -0
- package/dist/virtualList/index.js +392 -0
- package/dist/virtualList/public.d.ts +3 -0
- package/dist/virtualList/public.d.ts.map +1 -0
- package/dist/virtualList/public.js +1 -0
- package/dist/vitest-setup.d.ts +2 -0
- package/dist/vitest-setup.d.ts.map +1 -0
- package/dist/vitest-setup.js +2 -0
- package/package.json +161 -0
|
@@ -0,0 +1,708 @@
|
|
|
1
|
+
import { Array, Effect, Match as M, Option, Predicate, Result, Schema as S, pipe, } from 'effect';
|
|
2
|
+
import * as Command from 'foldkit/command';
|
|
3
|
+
import * as Dom from 'foldkit/dom';
|
|
4
|
+
import { html } from 'foldkit/html';
|
|
5
|
+
import { m } from 'foldkit/message';
|
|
6
|
+
import * as Mount from 'foldkit/mount';
|
|
7
|
+
import { makeConstrainedEvo } from 'foldkit/struct';
|
|
8
|
+
import { defineView } from 'foldkit/submodel';
|
|
9
|
+
import { AnchorConfig, anchorSetup, portalToBody } from '../anchor.js';
|
|
10
|
+
// NOTE: Animation imports are split across schema + update to avoid a circular
|
|
11
|
+
// dependency: animation → html → runtime → devtools → combobox → animation.
|
|
12
|
+
// The barrel (../animation) imports from html, which starts the cycle.
|
|
13
|
+
import { EndedAnimation as AnimationEndedAnimation, Hid as AnimationHid, Message as AnimationMessage, Model as AnimationModel, Showed as AnimationShowed, init as animationInit, } from '../animation/schema.js';
|
|
14
|
+
import { update as animationUpdate } from '../animation/update.js';
|
|
15
|
+
import { groupContiguous } from '../group.js';
|
|
16
|
+
import * as OptionExt from '../internal/optionExtensions.js';
|
|
17
|
+
import { findFirstEnabledIndex, keyToIndex } from '../keyboard.js';
|
|
18
|
+
export { groupContiguous };
|
|
19
|
+
// MODEL
|
|
20
|
+
/** Schema for the activation trigger: whether the user interacted via mouse or keyboard. */
|
|
21
|
+
export const ActivationTrigger = S.Literals(['Pointer', 'Keyboard']);
|
|
22
|
+
/** Schema fields shared by all combobox variants (single-select and multi-select). Spread into each variant's `S.Struct` to avoid duplicating field definitions. */
|
|
23
|
+
export const BaseModel = S.Struct({
|
|
24
|
+
id: S.String,
|
|
25
|
+
isOpen: S.Boolean,
|
|
26
|
+
isAnimated: S.Boolean,
|
|
27
|
+
isModal: S.Boolean,
|
|
28
|
+
nullable: S.Boolean,
|
|
29
|
+
immediate: S.Boolean,
|
|
30
|
+
selectInputOnFocus: S.Boolean,
|
|
31
|
+
animation: AnimationModel,
|
|
32
|
+
maybeActiveItemIndex: S.Option(S.Number),
|
|
33
|
+
activationTrigger: ActivationTrigger,
|
|
34
|
+
inputValue: S.String,
|
|
35
|
+
maybeLastPointerPosition: S.Option(S.Struct({ screenX: S.Number, screenY: S.Number })),
|
|
36
|
+
});
|
|
37
|
+
/** Creates the shared base fields for a combobox model from a config. Each variant spreads this and adds its selection fields. */
|
|
38
|
+
export const baseInit = (config) => ({
|
|
39
|
+
id: config.id,
|
|
40
|
+
isOpen: false,
|
|
41
|
+
isAnimated: config.isAnimated ?? false,
|
|
42
|
+
isModal: config.isModal ?? false,
|
|
43
|
+
nullable: config.nullable ?? false,
|
|
44
|
+
immediate: config.immediate ?? false,
|
|
45
|
+
selectInputOnFocus: config.selectInputOnFocus ?? false,
|
|
46
|
+
animation: animationInit({ id: `${config.id}-items` }),
|
|
47
|
+
maybeActiveItemIndex: Option.none(),
|
|
48
|
+
activationTrigger: 'Keyboard',
|
|
49
|
+
inputValue: '',
|
|
50
|
+
maybeLastPointerPosition: Option.none(),
|
|
51
|
+
});
|
|
52
|
+
// MESSAGE
|
|
53
|
+
/** Sent when the combobox popup opens. Contains an optional initial active item index. */
|
|
54
|
+
export const Opened = m('Opened', {
|
|
55
|
+
maybeActiveItemIndex: S.Option(S.Number),
|
|
56
|
+
});
|
|
57
|
+
/** Sent when the combobox closes via Escape key or backdrop click. */
|
|
58
|
+
export const Closed = m('Closed');
|
|
59
|
+
/** Sent when the combobox input loses focus. */
|
|
60
|
+
export const BlurredInput = m('BlurredInput');
|
|
61
|
+
/** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger and optional immediate selection info. */
|
|
62
|
+
export const ActivatedItem = m('ActivatedItem', {
|
|
63
|
+
index: S.Number,
|
|
64
|
+
activationTrigger: ActivationTrigger,
|
|
65
|
+
maybeImmediateSelection: S.Option(S.Struct({ item: S.String, displayText: S.String })),
|
|
66
|
+
});
|
|
67
|
+
/** Sent when the mouse leaves an enabled item. */
|
|
68
|
+
export const DeactivatedItem = m('DeactivatedItem');
|
|
69
|
+
/** Sent when an item is selected via Enter or click. Includes display text for restoring input value on close. */
|
|
70
|
+
export const SelectedItem = m('SelectedItem', {
|
|
71
|
+
item: S.String,
|
|
72
|
+
displayText: S.String,
|
|
73
|
+
});
|
|
74
|
+
/** Sent when the pointer moves over a combobox item. */
|
|
75
|
+
export const MovedPointerOverItem = m('MovedPointerOverItem', {
|
|
76
|
+
index: S.Number,
|
|
77
|
+
screenX: S.Number,
|
|
78
|
+
screenY: S.Number,
|
|
79
|
+
});
|
|
80
|
+
/** Sent when Enter or Space is pressed on the active item, triggering a programmatic click. */
|
|
81
|
+
export const RequestedItemClick = m('RequestedItemClick', {
|
|
82
|
+
index: S.Number,
|
|
83
|
+
});
|
|
84
|
+
/** Sent when the scroll lock command completes. */
|
|
85
|
+
export const CompletedLockScroll = m('CompletedLockScroll');
|
|
86
|
+
/** Sent when the scroll unlock command completes. */
|
|
87
|
+
export const CompletedUnlockScroll = m('CompletedUnlockScroll');
|
|
88
|
+
/** Sent when the inert-others command completes. */
|
|
89
|
+
export const CompletedInertOthers = m('CompletedInertOthers');
|
|
90
|
+
/** Sent when the restore-inert command completes. */
|
|
91
|
+
export const CompletedRestoreInert = m('CompletedRestoreInert');
|
|
92
|
+
/** Sent when the focus-input command completes. */
|
|
93
|
+
export const CompletedFocusInput = m('CompletedFocusInput');
|
|
94
|
+
/** Sent when the scroll-into-view command completes after keyboard activation. */
|
|
95
|
+
export const CompletedScrollIntoView = m('CompletedScrollIntoView');
|
|
96
|
+
/** Sent when the programmatic item click command completes. */
|
|
97
|
+
export const CompletedClickItem = m('CompletedClickItem');
|
|
98
|
+
/** Sent when the items panel mounts and Floating UI has positioned it. Update no-ops; surfaces the positioning side effect for DevTools. */
|
|
99
|
+
export const CompletedAnchorCombobox = m('CompletedAnchorCombobox');
|
|
100
|
+
/** Sent when the items panel mounts and the capture-phase pointerdown listener is attached (with or without anchor). Update no-ops; surfaces the listener-attach side effect for DevTools. */
|
|
101
|
+
export const CompletedAttachComboboxPreventBlur = m('CompletedAttachComboboxPreventBlur');
|
|
102
|
+
/** Sent when the input mounts and the focus listener that auto-selects on focus is attached. Update no-ops; surfaces the listener-attach side effect for DevTools. */
|
|
103
|
+
export const CompletedAttachComboboxSelectOnFocus = m('CompletedAttachComboboxSelectOnFocus');
|
|
104
|
+
/** Sent when the combobox backdrop mounts and is portaled to the document body. Update no-ops; surfaces the portal side effect for DevTools. */
|
|
105
|
+
export const CompletedPortalComboboxBackdrop = m('CompletedPortalComboboxBackdrop');
|
|
106
|
+
/** Wraps an Animation submodel message for delegation. */
|
|
107
|
+
export const GotAnimationMessage = m('GotAnimationMessage', {
|
|
108
|
+
message: AnimationMessage,
|
|
109
|
+
});
|
|
110
|
+
/** Sent when the user types in the input. */
|
|
111
|
+
export const UpdatedInputValue = m('UpdatedInputValue', {
|
|
112
|
+
value: S.String,
|
|
113
|
+
});
|
|
114
|
+
/** Sent when the optional toggle button is clicked. */
|
|
115
|
+
export const PressedToggleButton = m('PressedToggleButton');
|
|
116
|
+
/** Union of all messages the combobox component can produce. */
|
|
117
|
+
export const Message = S.Union([
|
|
118
|
+
Opened,
|
|
119
|
+
Closed,
|
|
120
|
+
BlurredInput,
|
|
121
|
+
ActivatedItem,
|
|
122
|
+
DeactivatedItem,
|
|
123
|
+
SelectedItem,
|
|
124
|
+
MovedPointerOverItem,
|
|
125
|
+
RequestedItemClick,
|
|
126
|
+
CompletedLockScroll,
|
|
127
|
+
CompletedUnlockScroll,
|
|
128
|
+
CompletedInertOthers,
|
|
129
|
+
CompletedRestoreInert,
|
|
130
|
+
CompletedFocusInput,
|
|
131
|
+
CompletedScrollIntoView,
|
|
132
|
+
CompletedClickItem,
|
|
133
|
+
CompletedAnchorCombobox,
|
|
134
|
+
CompletedAttachComboboxPreventBlur,
|
|
135
|
+
CompletedAttachComboboxSelectOnFocus,
|
|
136
|
+
CompletedPortalComboboxBackdrop,
|
|
137
|
+
GotAnimationMessage,
|
|
138
|
+
UpdatedInputValue,
|
|
139
|
+
PressedToggleButton,
|
|
140
|
+
]);
|
|
141
|
+
// OUT MESSAGE
|
|
142
|
+
/** Sent when a single-select combobox commits a selection, or when a multi-select combobox toggles an item on. The `value` is the string key; consumers that need a richer domain type should look it up from their own state or, in the multi case, branch on `wasAdded` to distinguish add vs remove. */
|
|
143
|
+
export const Selected = m('Selected', {
|
|
144
|
+
value: S.String,
|
|
145
|
+
wasAdded: S.Boolean,
|
|
146
|
+
});
|
|
147
|
+
/** Union of out-messages the combobox component can produce. Single-select comboboxes always emit `wasAdded: true`. Multi-select comboboxes emit `wasAdded: true` when adding to the selection and `wasAdded: false` when toggling off. */
|
|
148
|
+
export const OutMessage = S.Union([Selected]);
|
|
149
|
+
// SELECTORS
|
|
150
|
+
export const inputSelector = (id) => `#${id}-input`;
|
|
151
|
+
export const inputWrapperSelector = (id) => `#${id}-input-wrapper`;
|
|
152
|
+
export const itemsSelector = (id) => `#${id}-items`;
|
|
153
|
+
export const itemSelector = (id, index) => `#${id}-item-${index}`;
|
|
154
|
+
export const itemId = (id, index) => `${id}-item-${index}`;
|
|
155
|
+
// HELPERS
|
|
156
|
+
const constrainedEvo = makeConstrainedEvo();
|
|
157
|
+
/** Resets only shared base fields to their closed state. Does not touch inputValue or selection. Those are variant-specific. */
|
|
158
|
+
export const closedBaseModel = (model) => constrainedEvo(model, {
|
|
159
|
+
isOpen: () => false,
|
|
160
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
161
|
+
activationTrigger: () => 'Keyboard',
|
|
162
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
163
|
+
});
|
|
164
|
+
/** Prevents page scrolling while the combobox popup is open in modal mode. */
|
|
165
|
+
export const LockScroll = Command.define('LockScroll', CompletedLockScroll)(Dom.lockScroll.pipe(Effect.as(CompletedLockScroll())));
|
|
166
|
+
/** Re-enables page scrolling after the combobox popup closes. */
|
|
167
|
+
export const UnlockScroll = Command.define('UnlockScroll', CompletedUnlockScroll)(Dom.unlockScroll.pipe(Effect.as(CompletedUnlockScroll())));
|
|
168
|
+
/** Marks all elements outside the combobox as inert for modal behavior. */
|
|
169
|
+
export const InertOthers = Command.define('InertOthers', { id: S.String }, CompletedInertOthers)(({ id }) => Dom.inertOthers(id, [inputWrapperSelector(id), itemsSelector(id)]).pipe(Effect.as(CompletedInertOthers())));
|
|
170
|
+
/** Removes the inert attribute from elements outside the combobox. */
|
|
171
|
+
export const RestoreInert = Command.define('RestoreInert', { id: S.String }, CompletedRestoreInert)(({ id }) => Dom.restoreInert(id).pipe(Effect.as(CompletedRestoreInert())));
|
|
172
|
+
/** Moves focus to the combobox input after selection or close. */
|
|
173
|
+
export const FocusInput = Command.define('FocusInput', { id: S.String }, CompletedFocusInput)(({ id }) => Dom.focus(inputSelector(id)).pipe(Effect.ignore, Effect.as(CompletedFocusInput())));
|
|
174
|
+
/** Scrolls the active combobox item into view after keyboard navigation. */
|
|
175
|
+
export const ScrollIntoView = Command.define('ScrollIntoView', { id: S.String, index: S.Number }, CompletedScrollIntoView)(({ id, index }) => Dom.scrollIntoView(itemSelector(id, index)).pipe(Effect.ignore, Effect.as(CompletedScrollIntoView())));
|
|
176
|
+
/** Programmatically clicks the active combobox item's DOM element. */
|
|
177
|
+
export const ClickItem = Command.define('ClickItem', { id: S.String, index: S.Number }, CompletedClickItem)(({ id, index }) => Dom.clickElement(itemSelector(id, index)).pipe(Effect.ignore, Effect.as(CompletedClickItem())));
|
|
178
|
+
/** Detects whether the combobox input wrapper moved or the leave animation ended. Whichever comes first; both outcomes signal the Animation submodel that leave is complete. */
|
|
179
|
+
export const DetectMovementOrAnimationEnd = Command.define('DetectMovementOrAnimationEnd', { id: S.String }, GotAnimationMessage)(({ id }) => Effect.raceFirst(Dom.detectElementMovement(inputWrapperSelector(id)).pipe(Effect.as(GotAnimationMessage({ message: AnimationEndedAnimation() }))), Dom.waitForAnimationSettled(itemsSelector(id)).pipe(Effect.as(GotAnimationMessage({ message: AnimationEndedAnimation() })))));
|
|
180
|
+
const delegateToAnimation = (model, animationMessage) => {
|
|
181
|
+
const [nextAnimation, animationCommands, maybeOutMessage] = animationUpdate(model.animation, animationMessage);
|
|
182
|
+
const mappedCommands = Command.mapMessages(animationCommands, message => GotAnimationMessage({ message }));
|
|
183
|
+
const additionalCommands = Option.match(maybeOutMessage, {
|
|
184
|
+
onNone: () => [],
|
|
185
|
+
onSome: M.type().pipe(M.tagsExhaustive({
|
|
186
|
+
StartedLeaveAnimating: () => [
|
|
187
|
+
DetectMovementOrAnimationEnd({ id: model.id }),
|
|
188
|
+
],
|
|
189
|
+
TransitionedOut: () => [],
|
|
190
|
+
})),
|
|
191
|
+
});
|
|
192
|
+
return [
|
|
193
|
+
constrainedEvo(model, { animation: () => nextAnimation }),
|
|
194
|
+
[...mappedCommands, ...additionalCommands],
|
|
195
|
+
Option.none(),
|
|
196
|
+
];
|
|
197
|
+
};
|
|
198
|
+
/** Creates a combobox update function from variant-specific handlers. Shared logic (open, close, activate, transition) is handled internally; only close, selection, and immediate-activation behavior varies by variant. */
|
|
199
|
+
export const makeUpdate = (handlers) => {
|
|
200
|
+
const withUpdateReturn = M.withReturnType();
|
|
201
|
+
const internalUpdate = (model, message) => {
|
|
202
|
+
const maybeLockScroll = OptionExt.when(model.isModal, LockScroll());
|
|
203
|
+
const maybeUnlockScroll = OptionExt.when(model.isModal, UnlockScroll());
|
|
204
|
+
const maybeInertOthers = OptionExt.when(model.isModal, InertOthers({ id: model.id }));
|
|
205
|
+
const maybeRestoreInert = OptionExt.when(model.isModal, RestoreInert({ id: model.id }));
|
|
206
|
+
const focusInput = FocusInput({ id: model.id });
|
|
207
|
+
const openCombobox = (baseModel) => {
|
|
208
|
+
if (model.isAnimated) {
|
|
209
|
+
const [nextModel, animationCommands] = delegateToAnimation(baseModel, AnimationShowed());
|
|
210
|
+
return [
|
|
211
|
+
constrainedEvo(nextModel, { isOpen: () => true }),
|
|
212
|
+
[
|
|
213
|
+
...Array.getSomes([maybeLockScroll, maybeInertOthers]),
|
|
214
|
+
...animationCommands,
|
|
215
|
+
],
|
|
216
|
+
Option.none(),
|
|
217
|
+
];
|
|
218
|
+
}
|
|
219
|
+
return [
|
|
220
|
+
constrainedEvo(baseModel, { isOpen: () => true }),
|
|
221
|
+
Array.getSomes([maybeLockScroll, maybeInertOthers]),
|
|
222
|
+
Option.none(),
|
|
223
|
+
];
|
|
224
|
+
};
|
|
225
|
+
const closeCombobox = (baseModel, commands, maybeOutMessage = Option.none()) => {
|
|
226
|
+
const closed = handlers.handleClose(baseModel);
|
|
227
|
+
if (model.isAnimated) {
|
|
228
|
+
const [nextModel, animationCommands] = delegateToAnimation(closed, AnimationHid());
|
|
229
|
+
return [nextModel, [...commands, ...animationCommands], maybeOutMessage];
|
|
230
|
+
}
|
|
231
|
+
return [closed, commands, maybeOutMessage];
|
|
232
|
+
};
|
|
233
|
+
return M.value(message).pipe(withUpdateReturn, M.tag('CompletedLockScroll', 'CompletedUnlockScroll', 'CompletedInertOthers', 'CompletedRestoreInert', 'CompletedFocusInput', 'CompletedScrollIntoView', 'CompletedClickItem', 'CompletedAnchorCombobox', 'CompletedAttachComboboxPreventBlur', 'CompletedAttachComboboxSelectOnFocus', 'CompletedPortalComboboxBackdrop', () => [model, [], Option.none()]), M.tagsExhaustive({
|
|
234
|
+
Opened: ({ maybeActiveItemIndex }) => openCombobox(constrainedEvo(model, {
|
|
235
|
+
maybeActiveItemIndex: () => maybeActiveItemIndex,
|
|
236
|
+
activationTrigger: () => Option.match(maybeActiveItemIndex, {
|
|
237
|
+
onNone: () => 'Pointer',
|
|
238
|
+
onSome: () => 'Keyboard',
|
|
239
|
+
}),
|
|
240
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
241
|
+
})),
|
|
242
|
+
Closed: () => closeCombobox(model, [focusInput]),
|
|
243
|
+
BlurredInput: () => closeCombobox(model, []),
|
|
244
|
+
ActivatedItem: ({ index, activationTrigger, maybeImmediateSelection, }) => {
|
|
245
|
+
const highlightedModel = constrainedEvo(model, {
|
|
246
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
247
|
+
activationTrigger: () => activationTrigger,
|
|
248
|
+
});
|
|
249
|
+
const nextModel = Option.match(maybeImmediateSelection, {
|
|
250
|
+
onNone: () => highlightedModel,
|
|
251
|
+
onSome: ({ item, displayText }) => handlers.handleImmediateActivation(highlightedModel, item, displayText),
|
|
252
|
+
});
|
|
253
|
+
return [
|
|
254
|
+
nextModel,
|
|
255
|
+
activationTrigger === 'Keyboard'
|
|
256
|
+
? [ScrollIntoView({ id: model.id, index })]
|
|
257
|
+
: [],
|
|
258
|
+
Option.none(),
|
|
259
|
+
];
|
|
260
|
+
},
|
|
261
|
+
MovedPointerOverItem: ({ index, screenX, screenY }) => {
|
|
262
|
+
const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
|
|
263
|
+
if (isSamePosition) {
|
|
264
|
+
return [model, [], Option.none()];
|
|
265
|
+
}
|
|
266
|
+
return [
|
|
267
|
+
constrainedEvo(model, {
|
|
268
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
269
|
+
activationTrigger: () => 'Pointer',
|
|
270
|
+
maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
|
|
271
|
+
}),
|
|
272
|
+
[],
|
|
273
|
+
Option.none(),
|
|
274
|
+
];
|
|
275
|
+
},
|
|
276
|
+
DeactivatedItem: () => model.activationTrigger === 'Pointer'
|
|
277
|
+
? [
|
|
278
|
+
constrainedEvo(model, {
|
|
279
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
280
|
+
}),
|
|
281
|
+
[],
|
|
282
|
+
Option.none(),
|
|
283
|
+
]
|
|
284
|
+
: [model, [], Option.none()],
|
|
285
|
+
SelectedItem: ({ item, displayText }) => {
|
|
286
|
+
const [nextModel, commands, maybeOutMessage] = handlers.handleSelectedItem(model, item, displayText, {
|
|
287
|
+
focusInput,
|
|
288
|
+
maybeUnlockScroll,
|
|
289
|
+
maybeRestoreInert,
|
|
290
|
+
});
|
|
291
|
+
if (model.isOpen && !nextModel.isOpen && model.isAnimated) {
|
|
292
|
+
const [transitionedModel, animationCommands] = delegateToAnimation(nextModel, AnimationHid());
|
|
293
|
+
return [
|
|
294
|
+
transitionedModel,
|
|
295
|
+
[...commands, ...animationCommands],
|
|
296
|
+
maybeOutMessage,
|
|
297
|
+
];
|
|
298
|
+
}
|
|
299
|
+
return [nextModel, commands, maybeOutMessage];
|
|
300
|
+
},
|
|
301
|
+
RequestedItemClick: ({ index }) => [
|
|
302
|
+
model,
|
|
303
|
+
[ClickItem({ id: model.id, index })],
|
|
304
|
+
Option.none(),
|
|
305
|
+
],
|
|
306
|
+
UpdatedInputValue: ({ value }) => {
|
|
307
|
+
if (model.isOpen) {
|
|
308
|
+
return [
|
|
309
|
+
constrainedEvo(model, {
|
|
310
|
+
inputValue: () => value,
|
|
311
|
+
maybeActiveItemIndex: () => Option.some(0),
|
|
312
|
+
activationTrigger: () => 'Keyboard',
|
|
313
|
+
}),
|
|
314
|
+
[],
|
|
315
|
+
Option.none(),
|
|
316
|
+
];
|
|
317
|
+
}
|
|
318
|
+
return openCombobox(constrainedEvo(model, {
|
|
319
|
+
inputValue: () => value,
|
|
320
|
+
maybeActiveItemIndex: () => Option.some(0),
|
|
321
|
+
activationTrigger: () => 'Keyboard',
|
|
322
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
323
|
+
}));
|
|
324
|
+
},
|
|
325
|
+
PressedToggleButton: () => {
|
|
326
|
+
if (model.isOpen) {
|
|
327
|
+
return closeCombobox(model, [focusInput]);
|
|
328
|
+
}
|
|
329
|
+
const [nextModel, commands] = openCombobox(constrainedEvo(model, {
|
|
330
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
331
|
+
activationTrigger: () => 'Pointer',
|
|
332
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
333
|
+
}));
|
|
334
|
+
return [nextModel, [focusInput, ...commands], Option.none()];
|
|
335
|
+
},
|
|
336
|
+
GotAnimationMessage: ({ message: animationMessage }) => delegateToAnimation(model, animationMessage),
|
|
337
|
+
}));
|
|
338
|
+
};
|
|
339
|
+
return internalUpdate;
|
|
340
|
+
};
|
|
341
|
+
/** The anchor-positioning Mount this Combobox renders on its items panel.
|
|
342
|
+
* The panel is always anchored to the input wrapper via Floating UI and
|
|
343
|
+
* portaled to the document body (opt out of portaling with
|
|
344
|
+
* `anchor.portal: false`), so it escapes ancestor stacking contexts and
|
|
345
|
+
* overflow clipping. The Mount also installs the `pointerdown`-cancelling
|
|
346
|
+
* capture listener that prevents input blur on item presses. Exposed so
|
|
347
|
+
* Scene tests can call
|
|
348
|
+
* `Scene.Mount.resolve(AnchorCombobox, CompletedAnchorCombobox())`. */
|
|
349
|
+
export const AnchorCombobox = Mount.define('AnchorCombobox', { buttonId: S.String, anchor: AnchorConfig }, CompletedAnchorCombobox)(({ buttonId, anchor }) => element => Effect.gen(function* () {
|
|
350
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
351
|
+
const preventBlur = (event) => {
|
|
352
|
+
event.preventDefault();
|
|
353
|
+
};
|
|
354
|
+
element.addEventListener('pointerdown', preventBlur, {
|
|
355
|
+
capture: true,
|
|
356
|
+
});
|
|
357
|
+
const teardownAnchor = anchorSetup({
|
|
358
|
+
buttonId,
|
|
359
|
+
anchor,
|
|
360
|
+
interceptTab: false,
|
|
361
|
+
})(element);
|
|
362
|
+
return () => {
|
|
363
|
+
element.removeEventListener('pointerdown', preventBlur, {
|
|
364
|
+
capture: true,
|
|
365
|
+
});
|
|
366
|
+
teardownAnchor();
|
|
367
|
+
};
|
|
368
|
+
}), cleanup => Effect.sync(cleanup));
|
|
369
|
+
return CompletedAnchorCombobox();
|
|
370
|
+
}));
|
|
371
|
+
/** The Mount this Combobox renders to install a `pointerdown`-cancelling
|
|
372
|
+
* capture listener that prevents blur on item presses. Exposed so Scene
|
|
373
|
+
* tests can call
|
|
374
|
+
* `Scene.Mount.resolve(AttachComboboxPreventBlur, CompletedAttachComboboxPreventBlur())`. */
|
|
375
|
+
export const AttachComboboxPreventBlur = Mount.define('AttachComboboxPreventBlur', CompletedAttachComboboxPreventBlur)(element => Effect.gen(function* () {
|
|
376
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
377
|
+
const handler = (event) => {
|
|
378
|
+
event.preventDefault();
|
|
379
|
+
};
|
|
380
|
+
element.addEventListener('pointerdown', handler, { capture: true });
|
|
381
|
+
return handler;
|
|
382
|
+
}), handler => Effect.sync(() => element.removeEventListener('pointerdown', handler, {
|
|
383
|
+
capture: true,
|
|
384
|
+
})));
|
|
385
|
+
return CompletedAttachComboboxPreventBlur();
|
|
386
|
+
}));
|
|
387
|
+
/** The Mount this Combobox renders to install the input's select-on-focus
|
|
388
|
+
* behavior. Exposed so Scene tests can call
|
|
389
|
+
* `Scene.Mount.resolve(AttachComboboxSelectOnFocus, CompletedAttachComboboxSelectOnFocus())`. */
|
|
390
|
+
export const AttachComboboxSelectOnFocus = Mount.define('AttachComboboxSelectOnFocus', CompletedAttachComboboxSelectOnFocus)(element => Effect.gen(function* () {
|
|
391
|
+
yield* Effect.acquireRelease(Effect.sync(() => {
|
|
392
|
+
const handler = () => {
|
|
393
|
+
if (element instanceof HTMLInputElement) {
|
|
394
|
+
element.select();
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
element.addEventListener('focus', handler);
|
|
398
|
+
return handler;
|
|
399
|
+
}), handler => Effect.sync(() => element.removeEventListener('focus', handler)));
|
|
400
|
+
return CompletedAttachComboboxSelectOnFocus();
|
|
401
|
+
}));
|
|
402
|
+
/** The backdrop-portaling Mount this Combobox renders. Exposed so Scene tests can
|
|
403
|
+
* call `Scene.Mount.resolve(PortalComboboxBackdrop, CompletedPortalComboboxBackdrop())` to
|
|
404
|
+
* acknowledge the mount produced by the rendered backdrop. */
|
|
405
|
+
export const PortalComboboxBackdrop = Mount.define('PortalComboboxBackdrop', CompletedPortalComboboxBackdrop)(element => Effect.gen(function* () {
|
|
406
|
+
yield* Effect.acquireRelease(Effect.sync(() => portalToBody(element)), cleanup => Effect.sync(cleanup));
|
|
407
|
+
return CompletedPortalComboboxBackdrop();
|
|
408
|
+
}));
|
|
409
|
+
/** Creates a combobox view function from variant-specific behavior. Shared rendering logic (input, items, transitions, keyboard navigation) is handled internally; only selection display varies by variant. */
|
|
410
|
+
export const makeView = (behavior) => {
|
|
411
|
+
const impl = defineView((model, viewInputs) => {
|
|
412
|
+
const h = html();
|
|
413
|
+
const { id, isOpen, immediate, animation: { transitionState }, maybeActiveItemIndex, } = model;
|
|
414
|
+
const { items, itemToConfig, itemToValue, itemToDisplayText, isItemDisabled, inputClassName, inputAttributes = [], inputPlaceholder, inputWrapperClassName, inputWrapperAttributes = [], itemsClassName, itemsAttributes = [], itemsScrollClassName, itemsScrollAttributes = [], backdropClassName, backdropAttributes = [], className, attributes = [], buttonContent, buttonClassName, buttonAttributes = [], formName, isDisabled, isInvalid, openOnFocus, itemGroupKey, groupToHeading, groupClassName, groupAttributes = [], separatorClassName, separatorAttributes = [], anchor = {}, } = viewInputs;
|
|
415
|
+
const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
|
|
416
|
+
const isVisible = isOpen || isLeaving;
|
|
417
|
+
const animationAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
|
|
418
|
+
h.DataAttribute('closed', ''),
|
|
419
|
+
h.DataAttribute('enter', ''),
|
|
420
|
+
h.DataAttribute('transition', ''),
|
|
421
|
+
]), M.when('EnterAnimating', () => [
|
|
422
|
+
h.DataAttribute('enter', ''),
|
|
423
|
+
h.DataAttribute('transition', ''),
|
|
424
|
+
]), M.when('LeaveStart', () => [
|
|
425
|
+
h.DataAttribute('leave', ''),
|
|
426
|
+
h.DataAttribute('transition', ''),
|
|
427
|
+
]), M.when('LeaveAnimating', () => [
|
|
428
|
+
h.DataAttribute('closed', ''),
|
|
429
|
+
h.DataAttribute('leave', ''),
|
|
430
|
+
h.DataAttribute('transition', ''),
|
|
431
|
+
]), M.orElse(() => []));
|
|
432
|
+
const isDisabledAtIndex = (index) => Predicate.isNotUndefined(isItemDisabled) &&
|
|
433
|
+
pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
|
|
434
|
+
const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabledAtIndex)(0, 1);
|
|
435
|
+
const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabledAtIndex)(items.length - 1, -1);
|
|
436
|
+
const resolveActiveIndex = keyToIndex('ArrowDown', 'ArrowUp', items.length, Option.getOrElse(maybeActiveItemIndex, () => -1), isDisabledAtIndex);
|
|
437
|
+
const resolveImmediateSelection = (targetIndex) => OptionExt.when(immediate, pipe(items, Array.get(targetIndex), Option.match({
|
|
438
|
+
onNone: () => ({ item: '', displayText: '' }),
|
|
439
|
+
onSome: targetItem => ({
|
|
440
|
+
item: itemToValue(targetItem, targetIndex),
|
|
441
|
+
displayText: itemToDisplayText(targetItem, targetIndex),
|
|
442
|
+
}),
|
|
443
|
+
})));
|
|
444
|
+
const handleInputKeyDown = (key) => M.value(key).pipe(M.when('ArrowDown', () => {
|
|
445
|
+
if (!isOpen) {
|
|
446
|
+
return Option.some(Opened({
|
|
447
|
+
maybeActiveItemIndex: Option.some(firstEnabledIndex),
|
|
448
|
+
}));
|
|
449
|
+
}
|
|
450
|
+
const targetIndex = resolveActiveIndex('ArrowDown');
|
|
451
|
+
return Option.some(ActivatedItem({
|
|
452
|
+
index: targetIndex,
|
|
453
|
+
activationTrigger: 'Keyboard',
|
|
454
|
+
maybeImmediateSelection: resolveImmediateSelection(targetIndex),
|
|
455
|
+
}));
|
|
456
|
+
}), M.when('ArrowUp', () => {
|
|
457
|
+
if (!isOpen) {
|
|
458
|
+
return Option.some(Opened({
|
|
459
|
+
maybeActiveItemIndex: Option.some(lastEnabledIndex),
|
|
460
|
+
}));
|
|
461
|
+
}
|
|
462
|
+
const targetIndex = resolveActiveIndex('ArrowUp');
|
|
463
|
+
return Option.some(ActivatedItem({
|
|
464
|
+
index: targetIndex,
|
|
465
|
+
activationTrigger: 'Keyboard',
|
|
466
|
+
maybeImmediateSelection: resolveImmediateSelection(targetIndex),
|
|
467
|
+
}));
|
|
468
|
+
}), M.when('Enter', () => {
|
|
469
|
+
if (!isOpen) {
|
|
470
|
+
return Option.none();
|
|
471
|
+
}
|
|
472
|
+
return Option.map(maybeActiveItemIndex, index => RequestedItemClick({ index }));
|
|
473
|
+
}), M.when('Escape', () => {
|
|
474
|
+
if (!isOpen) {
|
|
475
|
+
return Option.none();
|
|
476
|
+
}
|
|
477
|
+
return Option.some(Closed());
|
|
478
|
+
}), M.whenOr('Home', 'End', () => {
|
|
479
|
+
if (!isOpen) {
|
|
480
|
+
return Option.none();
|
|
481
|
+
}
|
|
482
|
+
const targetIndex = resolveActiveIndex(key);
|
|
483
|
+
return Option.some(ActivatedItem({
|
|
484
|
+
index: targetIndex,
|
|
485
|
+
activationTrigger: 'Keyboard',
|
|
486
|
+
maybeImmediateSelection: resolveImmediateSelection(targetIndex),
|
|
487
|
+
}));
|
|
488
|
+
}), M.orElse(() => Option.none()));
|
|
489
|
+
const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
|
|
490
|
+
onNone: () => [],
|
|
491
|
+
onSome: index => [h.AriaActiveDescendant(itemId(id, index))],
|
|
492
|
+
});
|
|
493
|
+
const resolvedInputAttributes = [
|
|
494
|
+
h.Id(`${id}-input`),
|
|
495
|
+
h.Role('combobox'),
|
|
496
|
+
h.AriaExpanded(isVisible),
|
|
497
|
+
h.AriaControls(`${id}-items`),
|
|
498
|
+
h.Attribute('aria-autocomplete', 'list'),
|
|
499
|
+
h.Attribute('aria-haspopup', 'listbox'),
|
|
500
|
+
h.Autocomplete('off'),
|
|
501
|
+
h.Value(model.inputValue),
|
|
502
|
+
...maybeActiveDescendant,
|
|
503
|
+
...(inputPlaceholder ? [h.Placeholder(inputPlaceholder)] : []),
|
|
504
|
+
...(isDisabled
|
|
505
|
+
? [h.AriaDisabled(true), h.DataAttribute('disabled', '')]
|
|
506
|
+
: [
|
|
507
|
+
h.OnInput(value => UpdatedInputValue({ value })),
|
|
508
|
+
h.OnKeyDownPreventDefault(handleInputKeyDown),
|
|
509
|
+
h.OnBlur(BlurredInput()),
|
|
510
|
+
...(openOnFocus
|
|
511
|
+
? [h.OnFocus(Opened({ maybeActiveItemIndex: Option.none() }))]
|
|
512
|
+
: []),
|
|
513
|
+
]),
|
|
514
|
+
...(isInvalid
|
|
515
|
+
? [h.AriaInvalid(true), h.DataAttribute('invalid', '')]
|
|
516
|
+
: []),
|
|
517
|
+
...(isVisible ? [h.DataAttribute('open', '')] : []),
|
|
518
|
+
...(model.selectInputOnFocus
|
|
519
|
+
? [h.OnMount(AttachComboboxSelectOnFocus())]
|
|
520
|
+
: []),
|
|
521
|
+
...(inputClassName ? [h.Class(inputClassName)] : []),
|
|
522
|
+
...inputAttributes,
|
|
523
|
+
];
|
|
524
|
+
const anchorAttributes = [
|
|
525
|
+
h.Style({
|
|
526
|
+
position: 'absolute',
|
|
527
|
+
margin: '0',
|
|
528
|
+
visibility: 'hidden',
|
|
529
|
+
}),
|
|
530
|
+
h.OnMount(AnchorCombobox({
|
|
531
|
+
buttonId: `${id}-input-wrapper`,
|
|
532
|
+
anchor,
|
|
533
|
+
})),
|
|
534
|
+
];
|
|
535
|
+
const itemsContainerAttributes = [
|
|
536
|
+
h.Id(`${id}-items`),
|
|
537
|
+
h.Role('listbox'),
|
|
538
|
+
...(behavior.ariaMultiSelectable ? [h.AriaMultiSelectable(true)] : []),
|
|
539
|
+
h.AriaLabelledBy(`${id}-input`),
|
|
540
|
+
h.Tabindex(-1),
|
|
541
|
+
...anchorAttributes,
|
|
542
|
+
...animationAttributes,
|
|
543
|
+
...(itemsClassName ? [h.Class(itemsClassName)] : []),
|
|
544
|
+
...itemsAttributes,
|
|
545
|
+
];
|
|
546
|
+
const comboboxItems = Array.map(items, (item, index) => {
|
|
547
|
+
const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
|
|
548
|
+
const isDisabledItem = isDisabledAtIndex(index);
|
|
549
|
+
const isSelectedItem = behavior.isItemSelected(model, itemToValue(item, index));
|
|
550
|
+
const itemConfig = itemToConfig(item, {
|
|
551
|
+
isActive: isActiveItem,
|
|
552
|
+
isDisabled: isDisabledItem,
|
|
553
|
+
isSelected: isSelectedItem,
|
|
554
|
+
});
|
|
555
|
+
const isInteractive = !isDisabledItem && !isLeaving;
|
|
556
|
+
return h.keyed('div')(itemId(id, index), [
|
|
557
|
+
h.Id(itemId(id, index)),
|
|
558
|
+
h.Role('option'),
|
|
559
|
+
h.AriaSelected(isSelectedItem),
|
|
560
|
+
...(isActiveItem ? [h.DataAttribute('active', '')] : []),
|
|
561
|
+
...(isSelectedItem ? [h.DataAttribute('selected', '')] : []),
|
|
562
|
+
...(isDisabledItem
|
|
563
|
+
? [h.AriaDisabled(true), h.DataAttribute('disabled', '')]
|
|
564
|
+
: []),
|
|
565
|
+
...(isInteractive
|
|
566
|
+
? [
|
|
567
|
+
h.OnClick(SelectedItem({
|
|
568
|
+
item: itemToValue(item, index),
|
|
569
|
+
displayText: itemToDisplayText(item, index),
|
|
570
|
+
})),
|
|
571
|
+
...(isActiveItem
|
|
572
|
+
? []
|
|
573
|
+
: [
|
|
574
|
+
h.OnPointerMove((screenX, screenY, pointerType) => OptionExt.when(pointerType !== 'touch', MovedPointerOverItem({ index, screenX, screenY }))),
|
|
575
|
+
]),
|
|
576
|
+
h.OnPointerLeave(pointerType => OptionExt.when(pointerType !== 'touch', DeactivatedItem())),
|
|
577
|
+
]
|
|
578
|
+
: []),
|
|
579
|
+
...(itemConfig.className ? [h.Class(itemConfig.className)] : []),
|
|
580
|
+
], [itemConfig.content]);
|
|
581
|
+
});
|
|
582
|
+
const renderGroupedItems = () => {
|
|
583
|
+
if (!itemGroupKey) {
|
|
584
|
+
return comboboxItems;
|
|
585
|
+
}
|
|
586
|
+
const segments = groupContiguous(comboboxItems, (_, index) => Array.get(items, index).pipe(Option.match({
|
|
587
|
+
onNone: () => '',
|
|
588
|
+
onSome: item => itemGroupKey(item, index),
|
|
589
|
+
})));
|
|
590
|
+
return Array.flatMap(segments, (segment, segmentIndex) => {
|
|
591
|
+
const maybeHeading = Option.fromNullishOr(groupToHeading && groupToHeading(segment.key));
|
|
592
|
+
const headingId = `${id}-heading-${segment.key}`;
|
|
593
|
+
const headingElement = Option.match(maybeHeading, {
|
|
594
|
+
onNone: () => [],
|
|
595
|
+
onSome: heading => [
|
|
596
|
+
h.keyed('div')(headingId, [
|
|
597
|
+
h.Id(headingId),
|
|
598
|
+
h.Role('presentation'),
|
|
599
|
+
...(heading.className ? [h.Class(heading.className)] : []),
|
|
600
|
+
], [heading.content]),
|
|
601
|
+
],
|
|
602
|
+
});
|
|
603
|
+
const groupContent = [...headingElement, ...segment.items];
|
|
604
|
+
const groupElement = h.keyed('div')(`${id}-group-${segment.key}`, [
|
|
605
|
+
h.Role('group'),
|
|
606
|
+
...(Option.isSome(maybeHeading)
|
|
607
|
+
? [h.AriaLabelledBy(headingId)]
|
|
608
|
+
: []),
|
|
609
|
+
...(groupClassName ? [h.Class(groupClassName)] : []),
|
|
610
|
+
...groupAttributes,
|
|
611
|
+
], groupContent);
|
|
612
|
+
const separator = segmentIndex > 0 &&
|
|
613
|
+
(separatorClassName ||
|
|
614
|
+
Array.isReadonlyArrayNonEmpty(separatorAttributes))
|
|
615
|
+
? [
|
|
616
|
+
h.keyed('div')(`${id}-separator-${segmentIndex}`, [
|
|
617
|
+
h.Role('separator'),
|
|
618
|
+
...(separatorClassName
|
|
619
|
+
? [h.Class(separatorClassName)]
|
|
620
|
+
: []),
|
|
621
|
+
...separatorAttributes,
|
|
622
|
+
], []),
|
|
623
|
+
]
|
|
624
|
+
: [];
|
|
625
|
+
return [...separator, groupElement];
|
|
626
|
+
});
|
|
627
|
+
};
|
|
628
|
+
const backdrop = h.keyed('div')(`${id}-backdrop`, [
|
|
629
|
+
h.OnMount(PortalComboboxBackdrop()),
|
|
630
|
+
...(isLeaving ? [] : [h.OnClick(Closed())]),
|
|
631
|
+
...(backdropClassName ? [h.Class(backdropClassName)] : []),
|
|
632
|
+
...backdropAttributes,
|
|
633
|
+
], []);
|
|
634
|
+
const renderedItems = renderGroupedItems();
|
|
635
|
+
const scrollableItems = itemsScrollClassName ||
|
|
636
|
+
Array.isReadonlyArrayNonEmpty(itemsScrollAttributes)
|
|
637
|
+
? [
|
|
638
|
+
h.div([
|
|
639
|
+
...(itemsScrollClassName
|
|
640
|
+
? [h.Class(itemsScrollClassName)]
|
|
641
|
+
: []),
|
|
642
|
+
...itemsScrollAttributes,
|
|
643
|
+
], renderedItems),
|
|
644
|
+
]
|
|
645
|
+
: renderedItems;
|
|
646
|
+
const visibleContent = [
|
|
647
|
+
backdrop,
|
|
648
|
+
h.keyed('div')(`${id}-items-container`, itemsContainerAttributes, scrollableItems),
|
|
649
|
+
];
|
|
650
|
+
const resolvedInputWrapperAttributes = [
|
|
651
|
+
h.Id(`${id}-input-wrapper`),
|
|
652
|
+
...(inputWrapperClassName ? [h.Class(inputWrapperClassName)] : []),
|
|
653
|
+
...inputWrapperAttributes,
|
|
654
|
+
];
|
|
655
|
+
const toggleButton = buttonContent
|
|
656
|
+
? [
|
|
657
|
+
h.keyed('button')(`${id}-button`, [
|
|
658
|
+
h.Id(`${id}-button`),
|
|
659
|
+
h.Type('button'),
|
|
660
|
+
h.Tabindex(-1),
|
|
661
|
+
h.AriaControls(`${id}-items`),
|
|
662
|
+
h.AriaExpanded(isVisible),
|
|
663
|
+
h.Attribute('aria-haspopup', 'listbox'),
|
|
664
|
+
...(isDisabled
|
|
665
|
+
? [h.AriaDisabled(true), h.DataAttribute('disabled', '')]
|
|
666
|
+
: [h.OnClick(PressedToggleButton())]),
|
|
667
|
+
h.OnMount(AttachComboboxPreventBlur()),
|
|
668
|
+
...(buttonClassName ? [h.Class(buttonClassName)] : []),
|
|
669
|
+
...buttonAttributes,
|
|
670
|
+
], [buttonContent]),
|
|
671
|
+
]
|
|
672
|
+
: [];
|
|
673
|
+
const selectedValues = pipe(items, Array.filterMap((item, index) => {
|
|
674
|
+
const value = itemToValue(item, index);
|
|
675
|
+
return Result.fromOption(OptionExt.when(behavior.isItemSelected(model, value), value), () => undefined);
|
|
676
|
+
}));
|
|
677
|
+
const hiddenInputs = formName
|
|
678
|
+
? Array.match(selectedValues, {
|
|
679
|
+
onEmpty: () => [h.input([h.Type('hidden'), h.Name(formName)])],
|
|
680
|
+
onNonEmpty: Array.map(selectedValue => h.input([
|
|
681
|
+
h.Type('hidden'),
|
|
682
|
+
h.Name(formName),
|
|
683
|
+
h.Value(selectedValue),
|
|
684
|
+
])),
|
|
685
|
+
})
|
|
686
|
+
: [];
|
|
687
|
+
const wrapperAttributes = [
|
|
688
|
+
...(className ? [h.Class(className)] : []),
|
|
689
|
+
...attributes,
|
|
690
|
+
...(isVisible ? [h.DataAttribute('open', '')] : []),
|
|
691
|
+
...(isDisabled ? [h.DataAttribute('disabled', '')] : []),
|
|
692
|
+
...(isInvalid ? [h.DataAttribute('invalid', '')] : []),
|
|
693
|
+
];
|
|
694
|
+
return h.div(wrapperAttributes, [
|
|
695
|
+
h.div(resolvedInputWrapperAttributes, [
|
|
696
|
+
h.input(resolvedInputAttributes),
|
|
697
|
+
...toggleButton,
|
|
698
|
+
]),
|
|
699
|
+
...(isVisible && Array.isReadonlyArrayNonEmpty(items)
|
|
700
|
+
? visibleContent
|
|
701
|
+
: []),
|
|
702
|
+
...hiddenInputs,
|
|
703
|
+
]);
|
|
704
|
+
});
|
|
705
|
+
return () =>
|
|
706
|
+
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
707
|
+
impl;
|
|
708
|
+
};
|