foldkit 0.22.0 → 0.24.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/runtime/runtime.d.ts +49 -34
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +22 -8
- package/dist/task/dom.d.ts.map +1 -1
- package/dist/ui/group.d.ts +8 -0
- package/dist/ui/group.d.ts.map +1 -0
- package/dist/ui/group.js +13 -0
- package/dist/ui/index.d.ts +1 -0
- package/dist/ui/index.d.ts.map +1 -1
- package/dist/ui/index.js +1 -0
- package/dist/ui/keyboard.d.ts +2 -0
- package/dist/ui/keyboard.d.ts.map +1 -1
- package/dist/ui/keyboard.js +2 -0
- package/dist/ui/listbox/index.d.ts +182 -0
- package/dist/ui/listbox/index.d.ts.map +1 -0
- package/dist/ui/listbox/index.js +509 -0
- package/dist/ui/listbox/public.d.ts +4 -0
- package/dist/ui/listbox/public.d.ts.map +1 -0
- package/dist/ui/listbox/public.js +1 -0
- package/dist/ui/menu/index.d.ts +4 -9
- package/dist/ui/menu/index.d.ts.map +1 -1
- package/dist/ui/menu/index.js +9 -29
- package/dist/ui/typeahead.d.ts +4 -0
- package/dist/ui/typeahead.d.ts.map +1 -0
- package/dist/ui/typeahead.js +14 -0
- package/package.json +5 -1
|
@@ -0,0 +1,509 @@
|
|
|
1
|
+
import { Array, Effect, Match as M, Option, Predicate, Schema as S, String as Str, pipe, } from 'effect';
|
|
2
|
+
import { OptionExt } from '../../effectExtensions';
|
|
3
|
+
import { html } from '../../html';
|
|
4
|
+
import { m } from '../../message';
|
|
5
|
+
import { evo } from '../../struct';
|
|
6
|
+
import * as Task from '../../task';
|
|
7
|
+
import { groupContiguous } from '../group';
|
|
8
|
+
import { findFirstEnabledIndex, isPrintableKey, keyToIndex } from '../keyboard';
|
|
9
|
+
import { anchorHooks } from '../menu/anchor';
|
|
10
|
+
import { resolveTypeaheadMatch } from '../typeahead';
|
|
11
|
+
export { resolveTypeaheadMatch };
|
|
12
|
+
// MODEL
|
|
13
|
+
/** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
|
|
14
|
+
export const ActivationTrigger = S.Literal('Pointer', 'Keyboard');
|
|
15
|
+
/** Schema for the transition animation state, tracking enter/leave phases for CSS transition coordination. */
|
|
16
|
+
export const TransitionState = S.Literal('Idle', 'EnterStart', 'EnterAnimating', 'LeaveStart', 'LeaveAnimating');
|
|
17
|
+
/** Schema for the listbox orientation — whether items flow vertically or horizontally. */
|
|
18
|
+
export const Orientation = S.Literal('Vertical', 'Horizontal');
|
|
19
|
+
/** Schema for the listbox component's state, tracking open/closed status, active item, selected items, activation trigger, and typeahead search. */
|
|
20
|
+
export const Model = S.Struct({
|
|
21
|
+
id: S.String,
|
|
22
|
+
isOpen: S.Boolean,
|
|
23
|
+
isAnimated: S.Boolean,
|
|
24
|
+
isModal: S.Boolean,
|
|
25
|
+
isMultiple: S.Boolean,
|
|
26
|
+
orientation: Orientation,
|
|
27
|
+
transitionState: TransitionState,
|
|
28
|
+
maybeActiveItemIndex: S.OptionFromSelf(S.Number),
|
|
29
|
+
activationTrigger: ActivationTrigger,
|
|
30
|
+
searchQuery: S.String,
|
|
31
|
+
searchVersion: S.Number,
|
|
32
|
+
selectedItems: S.Array(S.String),
|
|
33
|
+
maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
|
|
34
|
+
maybeLastButtonPointerType: S.OptionFromSelf(S.String),
|
|
35
|
+
});
|
|
36
|
+
// MESSAGE
|
|
37
|
+
/** Sent when the listbox opens via button click or keyboard. Contains an optional initial active item index — None for pointer, Some for keyboard. */
|
|
38
|
+
export const Opened = m('Opened', {
|
|
39
|
+
maybeActiveItemIndex: S.OptionFromSelf(S.Number),
|
|
40
|
+
});
|
|
41
|
+
/** Sent when the listbox closes via Escape key or backdrop click. */
|
|
42
|
+
export const Closed = m('Closed');
|
|
43
|
+
/** Sent when focus leaves the listbox items container via Tab key or blur. */
|
|
44
|
+
export const ClosedByTab = m('ClosedByTab');
|
|
45
|
+
/** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger. */
|
|
46
|
+
export const ActivatedItem = m('ActivatedItem', {
|
|
47
|
+
index: S.Number,
|
|
48
|
+
activationTrigger: ActivationTrigger,
|
|
49
|
+
});
|
|
50
|
+
/** Sent when the mouse leaves an enabled item. */
|
|
51
|
+
export const DeactivatedItem = m('DeactivatedItem');
|
|
52
|
+
/** Sent when an item is selected via Enter, Space, or click. Contains the item's string value. */
|
|
53
|
+
export const SelectedItem = m('SelectedItem', { item: S.String });
|
|
54
|
+
/** Sent when Enter or Space is pressed on the active item, triggering a programmatic click on the DOM element. */
|
|
55
|
+
export const RequestedItemClick = m('RequestedItemClick', {
|
|
56
|
+
index: S.Number,
|
|
57
|
+
});
|
|
58
|
+
/** Sent when a printable character is typed for typeahead search. */
|
|
59
|
+
export const Searched = m('Searched', {
|
|
60
|
+
key: S.String,
|
|
61
|
+
maybeTargetIndex: S.OptionFromSelf(S.Number),
|
|
62
|
+
});
|
|
63
|
+
/** Sent after the search debounce period to clear the accumulated query. */
|
|
64
|
+
export const ClearedSearch = m('ClearedSearch', { version: S.Number });
|
|
65
|
+
/** Sent when the pointer moves over a listbox item, carrying screen coordinates for tracked-pointer comparison. */
|
|
66
|
+
export const MovedPointerOverItem = m('MovedPointerOverItem', {
|
|
67
|
+
index: S.Number,
|
|
68
|
+
screenX: S.Number,
|
|
69
|
+
screenY: S.Number,
|
|
70
|
+
});
|
|
71
|
+
/** Placeholder message used when no action is needed. */
|
|
72
|
+
export const NoOp = m('NoOp');
|
|
73
|
+
/** Sent internally when a double-rAF completes, advancing the transition to its animating phase. */
|
|
74
|
+
export const AdvancedTransitionFrame = m('AdvancedTransitionFrame');
|
|
75
|
+
/** Sent internally when all CSS transitions on the listbox items container have completed. */
|
|
76
|
+
export const EndedTransition = m('EndedTransition');
|
|
77
|
+
/** Sent internally when the listbox button moves in the viewport during a leave transition, cancelling the animation. */
|
|
78
|
+
export const DetectedButtonMovement = m('DetectedButtonMovement');
|
|
79
|
+
/** Sent when the user presses a pointer device on the listbox button. Records pointer type for click handling. */
|
|
80
|
+
export const PressedPointerOnButton = m('PressedPointerOnButton', {
|
|
81
|
+
pointerType: S.String,
|
|
82
|
+
button: S.Number,
|
|
83
|
+
});
|
|
84
|
+
/** Union of all messages the listbox component can produce. */
|
|
85
|
+
export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, DetectedButtonMovement, PressedPointerOnButton);
|
|
86
|
+
// INIT
|
|
87
|
+
const SEARCH_DEBOUNCE_MILLISECONDS = 350;
|
|
88
|
+
const LEFT_MOUSE_BUTTON = 0;
|
|
89
|
+
/** Creates an initial listbox model from a config. Defaults to closed with no active item and no selection. */
|
|
90
|
+
export const init = (config) => ({
|
|
91
|
+
id: config.id,
|
|
92
|
+
isOpen: false,
|
|
93
|
+
isAnimated: config.isAnimated ?? false,
|
|
94
|
+
isModal: config.isModal ?? false,
|
|
95
|
+
isMultiple: config.isMultiple ?? false,
|
|
96
|
+
orientation: config.orientation ?? 'Vertical',
|
|
97
|
+
transitionState: 'Idle',
|
|
98
|
+
maybeActiveItemIndex: Option.none(),
|
|
99
|
+
activationTrigger: 'Keyboard',
|
|
100
|
+
searchQuery: '',
|
|
101
|
+
searchVersion: 0,
|
|
102
|
+
selectedItems: config.selectedItems ?? [],
|
|
103
|
+
maybeLastPointerPosition: Option.none(),
|
|
104
|
+
maybeLastButtonPointerType: Option.none(),
|
|
105
|
+
});
|
|
106
|
+
// UPDATE
|
|
107
|
+
const closedModel = (model) => evo(model, {
|
|
108
|
+
isOpen: () => false,
|
|
109
|
+
transitionState: () => (model.isAnimated ? 'LeaveStart' : 'Idle'),
|
|
110
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
111
|
+
activationTrigger: () => 'Keyboard',
|
|
112
|
+
searchQuery: () => '',
|
|
113
|
+
searchVersion: () => 0,
|
|
114
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
115
|
+
maybeLastButtonPointerType: () => Option.none(),
|
|
116
|
+
});
|
|
117
|
+
const buttonSelector = (id) => `#${id}-button`;
|
|
118
|
+
const itemsSelector = (id) => `#${id}-items`;
|
|
119
|
+
const itemSelector = (id, index) => `#${id}-item-${index}`;
|
|
120
|
+
const withUpdateReturn = M.withReturnType();
|
|
121
|
+
/** Processes a listbox message and returns the next model and commands. */
|
|
122
|
+
export const update = (model, message) => {
|
|
123
|
+
const maybeNextFrame = OptionExt.when(model.isAnimated, Task.nextFrame.pipe(Effect.as(AdvancedTransitionFrame())));
|
|
124
|
+
const maybeLockScroll = OptionExt.when(model.isModal, Task.lockScroll.pipe(Effect.as(NoOp())));
|
|
125
|
+
const maybeUnlockScroll = OptionExt.when(model.isModal, Task.unlockScroll.pipe(Effect.as(NoOp())));
|
|
126
|
+
const maybeInertOthers = OptionExt.when(model.isModal, Task.inertOthers(model.id, [
|
|
127
|
+
buttonSelector(model.id),
|
|
128
|
+
itemsSelector(model.id),
|
|
129
|
+
]).pipe(Effect.as(NoOp())));
|
|
130
|
+
const maybeRestoreInert = OptionExt.when(model.isModal, Task.restoreInert(model.id).pipe(Effect.as(NoOp())));
|
|
131
|
+
const focusButton = Task.focus(buttonSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp()));
|
|
132
|
+
return M.value(message).pipe(withUpdateReturn, M.tagsExhaustive({
|
|
133
|
+
Opened: ({ maybeActiveItemIndex }) => {
|
|
134
|
+
const nextModel = evo(model, {
|
|
135
|
+
isOpen: () => true,
|
|
136
|
+
transitionState: () => (model.isAnimated ? 'EnterStart' : 'Idle'),
|
|
137
|
+
maybeActiveItemIndex: () => maybeActiveItemIndex,
|
|
138
|
+
activationTrigger: () => Option.match(maybeActiveItemIndex, {
|
|
139
|
+
onNone: () => 'Pointer',
|
|
140
|
+
onSome: () => 'Keyboard',
|
|
141
|
+
}),
|
|
142
|
+
searchQuery: () => '',
|
|
143
|
+
searchVersion: () => 0,
|
|
144
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
145
|
+
});
|
|
146
|
+
return [
|
|
147
|
+
nextModel,
|
|
148
|
+
pipe(Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
149
|
+
];
|
|
150
|
+
},
|
|
151
|
+
Closed: () => [
|
|
152
|
+
closedModel(model),
|
|
153
|
+
pipe(Array.getSomes([
|
|
154
|
+
maybeNextFrame,
|
|
155
|
+
maybeUnlockScroll,
|
|
156
|
+
maybeRestoreInert,
|
|
157
|
+
]), Array.prepend(focusButton)),
|
|
158
|
+
],
|
|
159
|
+
ClosedByTab: () => [
|
|
160
|
+
closedModel(model),
|
|
161
|
+
Array.getSomes([maybeNextFrame, maybeUnlockScroll, maybeRestoreInert]),
|
|
162
|
+
],
|
|
163
|
+
ActivatedItem: ({ index, activationTrigger }) => [
|
|
164
|
+
evo(model, {
|
|
165
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
166
|
+
activationTrigger: () => activationTrigger,
|
|
167
|
+
}),
|
|
168
|
+
activationTrigger === 'Keyboard'
|
|
169
|
+
? [
|
|
170
|
+
Task.scrollIntoView(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
|
|
171
|
+
]
|
|
172
|
+
: [],
|
|
173
|
+
],
|
|
174
|
+
MovedPointerOverItem: ({ index, screenX, screenY }) => {
|
|
175
|
+
const isSamePosition = Option.exists(model.maybeLastPointerPosition, position => position.screenX === screenX && position.screenY === screenY);
|
|
176
|
+
if (isSamePosition) {
|
|
177
|
+
return [model, []];
|
|
178
|
+
}
|
|
179
|
+
return [
|
|
180
|
+
evo(model, {
|
|
181
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
182
|
+
activationTrigger: () => 'Pointer',
|
|
183
|
+
maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
|
|
184
|
+
}),
|
|
185
|
+
[],
|
|
186
|
+
];
|
|
187
|
+
},
|
|
188
|
+
DeactivatedItem: () => model.activationTrigger === 'Pointer'
|
|
189
|
+
? [evo(model, { maybeActiveItemIndex: () => Option.none() }), []]
|
|
190
|
+
: [model, []],
|
|
191
|
+
SelectedItem: ({ item }) => {
|
|
192
|
+
if (model.isMultiple) {
|
|
193
|
+
const nextSelectedItems = Array.contains(model.selectedItems, item)
|
|
194
|
+
? Array.filter(model.selectedItems, selected => selected !== item)
|
|
195
|
+
: Array.append(model.selectedItems, item);
|
|
196
|
+
return [evo(model, { selectedItems: () => nextSelectedItems }), []];
|
|
197
|
+
}
|
|
198
|
+
return [
|
|
199
|
+
evo(closedModel(model), {
|
|
200
|
+
selectedItems: () => [item],
|
|
201
|
+
}),
|
|
202
|
+
pipe(Array.getSomes([
|
|
203
|
+
maybeNextFrame,
|
|
204
|
+
maybeUnlockScroll,
|
|
205
|
+
maybeRestoreInert,
|
|
206
|
+
]), Array.prepend(focusButton)),
|
|
207
|
+
];
|
|
208
|
+
},
|
|
209
|
+
RequestedItemClick: ({ index }) => [
|
|
210
|
+
model,
|
|
211
|
+
[
|
|
212
|
+
Task.clickElement(itemSelector(model.id, index)).pipe(Effect.ignore, Effect.as(NoOp())),
|
|
213
|
+
],
|
|
214
|
+
],
|
|
215
|
+
Searched: ({ key, maybeTargetIndex }) => {
|
|
216
|
+
const nextSearchQuery = model.searchQuery + key;
|
|
217
|
+
const nextSearchVersion = model.searchVersion + 1;
|
|
218
|
+
return [
|
|
219
|
+
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(withUpdateReturn, 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
|
+
Effect.raceFirst(Task.detectElementMovement(buttonSelector(model.id)).pipe(Effect.as(DetectedButtonMovement())), Task.waitForTransitions(itemsSelector(model.id)).pipe(Effect.as(EndedTransition()))),
|
|
244
|
+
],
|
|
245
|
+
]), M.orElse(() => [model, []])),
|
|
246
|
+
EndedTransition: () => M.value(model.transitionState).pipe(withUpdateReturn, M.whenOr('EnterAnimating', 'LeaveAnimating', () => [
|
|
247
|
+
evo(model, { transitionState: () => 'Idle' }),
|
|
248
|
+
[],
|
|
249
|
+
]), M.orElse(() => [model, []])),
|
|
250
|
+
DetectedButtonMovement: () => M.value(model.transitionState).pipe(withUpdateReturn, M.when('LeaveAnimating', () => [
|
|
251
|
+
evo(model, { transitionState: () => 'Idle' }),
|
|
252
|
+
[],
|
|
253
|
+
]), M.orElse(() => [model, []])),
|
|
254
|
+
PressedPointerOnButton: ({ pointerType, button }) => {
|
|
255
|
+
const withPointerType = evo(model, {
|
|
256
|
+
maybeLastButtonPointerType: () => Option.some(pointerType),
|
|
257
|
+
});
|
|
258
|
+
if (pointerType !== 'mouse' || button !== LEFT_MOUSE_BUTTON) {
|
|
259
|
+
return [withPointerType, []];
|
|
260
|
+
}
|
|
261
|
+
if (model.isOpen) {
|
|
262
|
+
return [
|
|
263
|
+
closedModel(withPointerType),
|
|
264
|
+
pipe(Array.getSomes([
|
|
265
|
+
maybeNextFrame,
|
|
266
|
+
maybeUnlockScroll,
|
|
267
|
+
maybeRestoreInert,
|
|
268
|
+
]), Array.prepend(focusButton)),
|
|
269
|
+
];
|
|
270
|
+
}
|
|
271
|
+
const nextModel = evo(withPointerType, {
|
|
272
|
+
isOpen: () => true,
|
|
273
|
+
transitionState: () => (model.isAnimated ? 'EnterStart' : 'Idle'),
|
|
274
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
275
|
+
activationTrigger: () => 'Pointer',
|
|
276
|
+
searchQuery: () => '',
|
|
277
|
+
searchVersion: () => 0,
|
|
278
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
279
|
+
});
|
|
280
|
+
return [
|
|
281
|
+
nextModel,
|
|
282
|
+
pipe(Array.getSomes([maybeNextFrame, maybeLockScroll, maybeInertOthers]), Array.prepend(Task.focus(itemsSelector(model.id)).pipe(Effect.ignore, Effect.as(NoOp())))),
|
|
283
|
+
];
|
|
284
|
+
},
|
|
285
|
+
NoOp: () => [model, []],
|
|
286
|
+
}));
|
|
287
|
+
};
|
|
288
|
+
const itemId = (id, index) => `${id}-item-${index}`;
|
|
289
|
+
/** Renders a headless listbox with typeahead search, keyboard navigation, selection tracking, and aria-activedescendant focus management. */
|
|
290
|
+
export const view = (config) => {
|
|
291
|
+
const { div, input, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, AriaOrientation, AriaSelected, Attribute, Class, DataAttribute, Id, Name, OnBlur, OnClick, OnDestroy, OnInsert, OnKeyDownPreventDefault, OnKeyUpPreventDefault, OnPointerDown, OnPointerLeave, OnPointerMove, Role, Style, Tabindex, Type, Value, keyed, } = html();
|
|
292
|
+
const { model: { id, isOpen, isMultiple, orientation, transitionState, maybeActiveItemIndex, selectedItems, searchQuery, maybeLastButtonPointerType, }, toMessage, items, itemToConfig, isItemDisabled, isButtonDisabled, buttonContent, buttonClassName, itemsClassName, backdropClassName, className, itemGroupKey, groupToHeading, groupClassName, separatorClassName, anchor, name, form, isDisabled, isInvalid, } = config;
|
|
293
|
+
const itemToValue = config.itemToValue ?? (item => String(item));
|
|
294
|
+
const itemToSearchText = config.itemToSearchText ?? (item => itemToValue(item));
|
|
295
|
+
const isLeaving = transitionState === 'LeaveStart' || transitionState === 'LeaveAnimating';
|
|
296
|
+
const isVisible = isOpen || isLeaving;
|
|
297
|
+
const transitionAttributes = M.value(transitionState).pipe(M.when('EnterStart', () => [
|
|
298
|
+
DataAttribute('closed', ''),
|
|
299
|
+
DataAttribute('enter', ''),
|
|
300
|
+
DataAttribute('transition', ''),
|
|
301
|
+
]), M.when('EnterAnimating', () => [
|
|
302
|
+
DataAttribute('enter', ''),
|
|
303
|
+
DataAttribute('transition', ''),
|
|
304
|
+
]), M.when('LeaveStart', () => [
|
|
305
|
+
DataAttribute('leave', ''),
|
|
306
|
+
DataAttribute('transition', ''),
|
|
307
|
+
]), M.when('LeaveAnimating', () => [
|
|
308
|
+
DataAttribute('closed', ''),
|
|
309
|
+
DataAttribute('leave', ''),
|
|
310
|
+
DataAttribute('transition', ''),
|
|
311
|
+
]), M.orElse(() => []));
|
|
312
|
+
const isItemDisabledByIndex = (index) => Predicate.isNotUndefined(isItemDisabled) &&
|
|
313
|
+
pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
|
|
314
|
+
const isButtonEffectivelyDisabled = isDisabled || isButtonDisabled;
|
|
315
|
+
const nextKey = orientation === 'Horizontal' ? 'ArrowRight' : 'ArrowDown';
|
|
316
|
+
const previousKey = orientation === 'Horizontal' ? 'ArrowLeft' : 'ArrowUp';
|
|
317
|
+
const navigationKeys = [
|
|
318
|
+
nextKey,
|
|
319
|
+
previousKey,
|
|
320
|
+
'Home',
|
|
321
|
+
'End',
|
|
322
|
+
'PageUp',
|
|
323
|
+
'PageDown',
|
|
324
|
+
];
|
|
325
|
+
const isNavigationKey = (key) => Array.contains(navigationKeys, key);
|
|
326
|
+
const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(0, 1);
|
|
327
|
+
const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isItemDisabledByIndex)(items.length - 1, -1);
|
|
328
|
+
const selectedItemIndex = pipe(Array.head(selectedItems), Option.flatMap(selectedItem => Array.findFirstIndex(items, item => itemToValue(item) === selectedItem)));
|
|
329
|
+
const handleButtonKeyDown = (key) => M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(toMessage(Opened({
|
|
330
|
+
maybeActiveItemIndex: Option.orElse(selectedItemIndex, () => Option.some(firstEnabledIndex)),
|
|
331
|
+
})))), M.when('ArrowUp', () => Option.some(toMessage(Opened({
|
|
332
|
+
maybeActiveItemIndex: Option.orElse(selectedItemIndex, () => Option.some(lastEnabledIndex)),
|
|
333
|
+
})))), M.orElse(() => Option.none()));
|
|
334
|
+
const handleButtonPointerDown = (pointerType, button) => Option.some(toMessage(PressedPointerOnButton({
|
|
335
|
+
pointerType,
|
|
336
|
+
button,
|
|
337
|
+
})));
|
|
338
|
+
const handleButtonClick = () => {
|
|
339
|
+
const isMouse = Option.exists(maybeLastButtonPointerType, type => type === 'mouse');
|
|
340
|
+
if (isMouse) {
|
|
341
|
+
return toMessage(NoOp());
|
|
342
|
+
}
|
|
343
|
+
else if (isOpen) {
|
|
344
|
+
return toMessage(Closed());
|
|
345
|
+
}
|
|
346
|
+
else {
|
|
347
|
+
return toMessage(Opened({ maybeActiveItemIndex: Option.none() }));
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
const handleSpaceKeyUp = (key) => OptionExt.when(key === ' ', toMessage(NoOp()));
|
|
351
|
+
const resolveActiveIndex = (key) => Option.match(maybeActiveItemIndex, {
|
|
352
|
+
onNone: () => M.value(key).pipe(M.whenOr(previousKey, 'End', 'PageDown', () => lastEnabledIndex), M.orElse(() => firstEnabledIndex)),
|
|
353
|
+
onSome: activeIndex => keyToIndex(nextKey, previousKey, items.length, activeIndex, isItemDisabledByIndex)(key),
|
|
354
|
+
});
|
|
355
|
+
const searchForKey = (key) => {
|
|
356
|
+
const nextQuery = searchQuery + key;
|
|
357
|
+
const maybeTargetIndex = resolveTypeaheadMatch(items, nextQuery, maybeActiveItemIndex, isItemDisabledByIndex, itemToSearchText, Str.isNonEmpty(searchQuery));
|
|
358
|
+
return Option.some(toMessage(Searched({ key, maybeTargetIndex })));
|
|
359
|
+
};
|
|
360
|
+
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)
|
|
361
|
+
? searchForKey(' ')
|
|
362
|
+
: Option.map(maybeActiveItemIndex, index => toMessage(RequestedItemClick({ index })))), M.when(isNavigationKey, () => Option.some(toMessage(ActivatedItem({
|
|
363
|
+
index: resolveActiveIndex(key),
|
|
364
|
+
activationTrigger: 'Keyboard',
|
|
365
|
+
})))), M.when(isPrintableKey, () => searchForKey(key)), M.orElse(() => Option.none()));
|
|
366
|
+
const buttonAttributes = [
|
|
367
|
+
Id(`${id}-button`),
|
|
368
|
+
Type('button'),
|
|
369
|
+
Class(buttonClassName),
|
|
370
|
+
AriaHasPopup('listbox'),
|
|
371
|
+
AriaExpanded(isVisible),
|
|
372
|
+
AriaControls(`${id}-items`),
|
|
373
|
+
...(isButtonEffectivelyDisabled
|
|
374
|
+
? [AriaDisabled(true), DataAttribute('disabled', '')]
|
|
375
|
+
: [
|
|
376
|
+
OnPointerDown(handleButtonPointerDown),
|
|
377
|
+
OnKeyDownPreventDefault(handleButtonKeyDown),
|
|
378
|
+
OnKeyUpPreventDefault(handleSpaceKeyUp),
|
|
379
|
+
OnClick(handleButtonClick()),
|
|
380
|
+
]),
|
|
381
|
+
...(isVisible ? [DataAttribute('open', '')] : []),
|
|
382
|
+
...(isInvalid ? [DataAttribute('invalid', '')] : []),
|
|
383
|
+
];
|
|
384
|
+
const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
|
|
385
|
+
onNone: () => [],
|
|
386
|
+
onSome: index => [AriaActiveDescendant(itemId(id, index))],
|
|
387
|
+
});
|
|
388
|
+
const hooks = anchor
|
|
389
|
+
? anchorHooks({ buttonId: `${id}-button`, anchor })
|
|
390
|
+
: undefined;
|
|
391
|
+
const anchorAttributes = hooks
|
|
392
|
+
? [
|
|
393
|
+
Style({ position: 'absolute', margin: '0', visibility: 'hidden' }),
|
|
394
|
+
OnInsert(hooks.onInsert),
|
|
395
|
+
OnDestroy(hooks.onDestroy),
|
|
396
|
+
]
|
|
397
|
+
: [];
|
|
398
|
+
const itemsContainerAttributes = [
|
|
399
|
+
Id(`${id}-items`),
|
|
400
|
+
Role('listbox'),
|
|
401
|
+
AriaOrientation(Str.toLowerCase(orientation)),
|
|
402
|
+
...(isMultiple ? [Attribute('aria-multiselectable', 'true')] : []),
|
|
403
|
+
AriaLabelledBy(`${id}-button`),
|
|
404
|
+
...maybeActiveDescendant,
|
|
405
|
+
Tabindex(0),
|
|
406
|
+
Class(itemsClassName),
|
|
407
|
+
...anchorAttributes,
|
|
408
|
+
...transitionAttributes,
|
|
409
|
+
...(isLeaving
|
|
410
|
+
? []
|
|
411
|
+
: [
|
|
412
|
+
OnKeyDownPreventDefault(handleItemsKeyDown),
|
|
413
|
+
OnKeyUpPreventDefault(handleSpaceKeyUp),
|
|
414
|
+
OnBlur(toMessage(ClosedByTab())),
|
|
415
|
+
]),
|
|
416
|
+
];
|
|
417
|
+
const listboxItems = Array.map(items, (item, index) => {
|
|
418
|
+
const isActiveItem = Option.exists(maybeActiveItemIndex, activeIndex => activeIndex === index);
|
|
419
|
+
const isDisabledItem = isItemDisabledByIndex(index);
|
|
420
|
+
const isSelectedItem = Array.contains(selectedItems, itemToValue(item));
|
|
421
|
+
const itemConfig = itemToConfig(item, {
|
|
422
|
+
isActive: isActiveItem,
|
|
423
|
+
isDisabled: isDisabledItem,
|
|
424
|
+
isSelected: isSelectedItem,
|
|
425
|
+
});
|
|
426
|
+
const isInteractive = !isDisabledItem && !isLeaving;
|
|
427
|
+
return keyed('div')(itemId(id, index), [
|
|
428
|
+
Id(itemId(id, index)),
|
|
429
|
+
Role('option'),
|
|
430
|
+
AriaSelected(isSelectedItem),
|
|
431
|
+
Class(itemConfig.className),
|
|
432
|
+
...(isActiveItem ? [DataAttribute('active', '')] : []),
|
|
433
|
+
...(isSelectedItem ? [DataAttribute('selected', '')] : []),
|
|
434
|
+
...(isDisabledItem
|
|
435
|
+
? [AriaDisabled(true), DataAttribute('disabled', '')]
|
|
436
|
+
: []),
|
|
437
|
+
...(isInteractive
|
|
438
|
+
? [
|
|
439
|
+
OnClick(toMessage(SelectedItem({ item: itemToValue(item) }))),
|
|
440
|
+
OnPointerMove((screenX, screenY, pointerType) => OptionExt.when(pointerType !== 'touch', toMessage(MovedPointerOverItem({ index, screenX, screenY })))),
|
|
441
|
+
OnPointerLeave(pointerType => OptionExt.when(pointerType !== 'touch', toMessage(DeactivatedItem()))),
|
|
442
|
+
]
|
|
443
|
+
: []),
|
|
444
|
+
], [itemConfig.content]);
|
|
445
|
+
});
|
|
446
|
+
const renderGroupedItems = () => {
|
|
447
|
+
if (!itemGroupKey) {
|
|
448
|
+
return listboxItems;
|
|
449
|
+
}
|
|
450
|
+
const segments = groupContiguous(listboxItems, (_, index) => Array.get(items, index).pipe(Option.match({
|
|
451
|
+
onNone: () => '',
|
|
452
|
+
onSome: item => itemGroupKey(item, index),
|
|
453
|
+
})));
|
|
454
|
+
return Array.flatMap(segments, (segment, segmentIndex) => {
|
|
455
|
+
const maybeHeading = Option.fromNullable(groupToHeading?.(segment.key));
|
|
456
|
+
const headingId = `${id}-heading-${segment.key}`;
|
|
457
|
+
const headingElement = Option.match(maybeHeading, {
|
|
458
|
+
onNone: () => [],
|
|
459
|
+
onSome: heading => [
|
|
460
|
+
keyed('div')(headingId, [Id(headingId), Role('presentation'), Class(heading.className)], [heading.content]),
|
|
461
|
+
],
|
|
462
|
+
});
|
|
463
|
+
const groupContent = [...headingElement, ...segment.items];
|
|
464
|
+
const groupElement = keyed('div')(`${id}-group-${segment.key}`, [
|
|
465
|
+
Role('group'),
|
|
466
|
+
...(Option.isSome(maybeHeading) ? [AriaLabelledBy(headingId)] : []),
|
|
467
|
+
...(groupClassName ? [Class(groupClassName)] : []),
|
|
468
|
+
], groupContent);
|
|
469
|
+
const separator = segmentIndex > 0 && separatorClassName
|
|
470
|
+
? [
|
|
471
|
+
keyed('div')(`${id}-separator-${segmentIndex}`, [Role('separator'), Class(separatorClassName)], []),
|
|
472
|
+
]
|
|
473
|
+
: [];
|
|
474
|
+
return [...separator, groupElement];
|
|
475
|
+
});
|
|
476
|
+
};
|
|
477
|
+
const backdrop = keyed('div')(`${id}-backdrop`, [
|
|
478
|
+
Class(backdropClassName),
|
|
479
|
+
...(isLeaving ? [] : [OnClick(toMessage(Closed()))]),
|
|
480
|
+
], []);
|
|
481
|
+
const renderedItems = renderGroupedItems();
|
|
482
|
+
const visibleContent = [
|
|
483
|
+
backdrop,
|
|
484
|
+
keyed('div')(`${id}-items-container`, itemsContainerAttributes, renderedItems),
|
|
485
|
+
];
|
|
486
|
+
const formAttribute = form ? [Attribute('form', form)] : [];
|
|
487
|
+
const hiddenInputs = name
|
|
488
|
+
? Array.match(selectedItems, {
|
|
489
|
+
onEmpty: () => [input([Type('hidden'), Name(name), ...formAttribute])],
|
|
490
|
+
onNonEmpty: Array.map(selectedItem => input([
|
|
491
|
+
Type('hidden'),
|
|
492
|
+
Name(name),
|
|
493
|
+
Value(selectedItem),
|
|
494
|
+
...formAttribute,
|
|
495
|
+
])),
|
|
496
|
+
})
|
|
497
|
+
: [];
|
|
498
|
+
const wrapperAttributes = [
|
|
499
|
+
...(className ? [Class(className)] : []),
|
|
500
|
+
...(isVisible ? [DataAttribute('open', '')] : []),
|
|
501
|
+
...(isDisabled ? [DataAttribute('disabled', '')] : []),
|
|
502
|
+
...(isInvalid ? [DataAttribute('invalid', '')] : []),
|
|
503
|
+
];
|
|
504
|
+
return div(wrapperAttributes, [
|
|
505
|
+
keyed('button')(`${id}-button`, buttonAttributes, [buttonContent]),
|
|
506
|
+
...hiddenInputs,
|
|
507
|
+
...(isVisible ? visibleContent : []),
|
|
508
|
+
]);
|
|
509
|
+
};
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { init, update, view, Model, Message, TransitionState, Orientation, } 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
|
+
export type { AnchorConfig } from '../menu/anchor';
|
|
4
|
+
//# sourceMappingURL=public.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/listbox/public.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,IAAI,EACJ,MAAM,EACN,IAAI,EACJ,KAAK,EACL,OAAO,EACP,eAAe,EACf,WAAW,GACZ,MAAM,SAAS,CAAA;AAEhB,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,gBAAgB,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { init, update, view, Model, Message, TransitionState, Orientation, } from './index';
|
package/dist/ui/menu/index.d.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { Schema as S } from 'effect';
|
|
2
2
|
import type { Command } from '../../command';
|
|
3
3
|
import { type Html } from '../../html';
|
|
4
|
+
import { groupContiguous } from '../group';
|
|
5
|
+
import { resolveTypeaheadMatch } from '../typeahead';
|
|
4
6
|
import type { AnchorConfig } from './anchor';
|
|
5
7
|
/** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
|
|
6
8
|
export declare const ActivationTrigger: S.Literal<["Pointer", "Keyboard"]>;
|
|
@@ -182,14 +184,7 @@ export type ViewConfig<Message, Item extends string> = Readonly<{
|
|
|
182
184
|
separatorClassName?: string;
|
|
183
185
|
anchor?: AnchorConfig;
|
|
184
186
|
}>;
|
|
185
|
-
|
|
186
|
-
key: string;
|
|
187
|
-
items: ReadonlyArray<A>;
|
|
188
|
-
}>;
|
|
189
|
-
export declare const groupContiguous: <A>(items: ReadonlyArray<A>, toKey: (item: A, index: number) => string) => ReadonlyArray<Segment<A>>;
|
|
190
|
-
/** Finds the first enabled item whose search text starts with the query, searching forward from the active item and wrapping around. On a fresh search, starts after the active item; on a refinement, includes the active item. */
|
|
191
|
-
export declare const resolveTypeaheadMatch: <Item extends string>(items: ReadonlyArray<Item>, query: string, maybeActiveItemIndex: Option.Option<number>, isDisabled: (index: number) => boolean, itemToSearchText: (item: Item, index: number) => string, isRefinement: boolean) => Option.Option<number>;
|
|
187
|
+
export { groupContiguous, resolveTypeaheadMatch };
|
|
192
188
|
/** Renders a headless menu with typeahead search, keyboard navigation, and aria-activedescendant focus management. */
|
|
193
189
|
export declare const view: <Message, Item extends string>(config: ViewConfig<Message, Item>) => Html;
|
|
194
|
-
export {};
|
|
195
190
|
//# sourceMappingURL=index.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ui/menu/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ui/menu/index.ts"],"names":[],"mappings":"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"}
|
package/dist/ui/menu/index.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { Array, Effect, Match as M, Option, Schema as S, String as Str, pipe, } from 'effect';
|
|
1
|
+
import { Array, Effect, Match as M, Option, Predicate, Schema as S, String as Str, pipe, } from 'effect';
|
|
2
2
|
import { OptionExt } from '../../effectExtensions';
|
|
3
3
|
import { html } from '../../html';
|
|
4
4
|
import { m } from '../../message';
|
|
5
5
|
import { evo } from '../../struct';
|
|
6
6
|
import * as Task from '../../task';
|
|
7
|
-
import {
|
|
7
|
+
import { groupContiguous } from '../group';
|
|
8
|
+
import { findFirstEnabledIndex, isPrintableKey, keyToIndex } from '../keyboard';
|
|
9
|
+
import { resolveTypeaheadMatch } from '../typeahead';
|
|
8
10
|
import { anchorHooks } from './anchor';
|
|
9
11
|
// MODEL
|
|
10
12
|
/** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
|
|
@@ -92,6 +94,7 @@ export const ReleasedPointerOnItems = m('ReleasedPointerOnItems', {
|
|
|
92
94
|
export const Message = S.Union(Opened, Closed, ClosedByTab, ActivatedItem, DeactivatedItem, SelectedItem, MovedPointerOverItem, RequestedItemClick, Searched, ClearedSearch, NoOp, AdvancedTransitionFrame, EndedTransition, DetectedButtonMovement, PressedPointerOnButton, ReleasedPointerOnItems);
|
|
93
95
|
// INIT
|
|
94
96
|
const SEARCH_DEBOUNCE_MILLISECONDS = 350;
|
|
97
|
+
const LEFT_MOUSE_BUTTON = 0;
|
|
95
98
|
const POINTER_HOLD_THRESHOLD_MILLISECONDS = 200;
|
|
96
99
|
const POINTER_MOVEMENT_THRESHOLD_PIXELS = 5;
|
|
97
100
|
/** Creates an initial menu model from a config. Defaults to closed with no active item. */
|
|
@@ -251,7 +254,7 @@ export const update = (model, message) => {
|
|
|
251
254
|
const withPointerType = evo(model, {
|
|
252
255
|
maybeLastButtonPointerType: () => Option.some(pointerType),
|
|
253
256
|
});
|
|
254
|
-
if (pointerType !== 'mouse' || button !==
|
|
257
|
+
if (pointerType !== 'mouse' || button !== LEFT_MOUSE_BUTTON) {
|
|
255
258
|
return [withPointerType, []];
|
|
256
259
|
}
|
|
257
260
|
if (model.isOpen) {
|
|
@@ -303,30 +306,8 @@ export const update = (model, message) => {
|
|
|
303
306
|
NoOp: () => [model, []],
|
|
304
307
|
}));
|
|
305
308
|
};
|
|
306
|
-
export
|
|
307
|
-
const tagged = Array.map(items, (item, index) => ({
|
|
308
|
-
key: toKey(item, index),
|
|
309
|
-
item,
|
|
310
|
-
}));
|
|
311
|
-
return Array.chop(tagged, nonEmpty => {
|
|
312
|
-
const key = Array.headNonEmpty(nonEmpty).key;
|
|
313
|
-
const [matching, rest] = Array.span(nonEmpty, tagged => tagged.key === key);
|
|
314
|
-
return [{ key, items: Array.map(matching, ({ item }) => item) }, rest];
|
|
315
|
-
});
|
|
316
|
-
};
|
|
309
|
+
export { groupContiguous, resolveTypeaheadMatch };
|
|
317
310
|
const itemId = (id, index) => `${id}-item-${index}`;
|
|
318
|
-
/** Finds the first enabled item whose search text starts with the query, searching forward from the active item and wrapping around. On a fresh search, starts after the active item; on a refinement, includes the active item. */
|
|
319
|
-
export const resolveTypeaheadMatch = (items, query, maybeActiveItemIndex, isDisabled, itemToSearchText, isRefinement) => {
|
|
320
|
-
const lowerQuery = Str.toLowerCase(query);
|
|
321
|
-
const offset = isRefinement ? 0 : 1;
|
|
322
|
-
const startIndex = Option.match(maybeActiveItemIndex, {
|
|
323
|
-
onNone: () => 0,
|
|
324
|
-
onSome: index => index + offset,
|
|
325
|
-
});
|
|
326
|
-
const isEnabledMatch = (index) => !isDisabled(index) &&
|
|
327
|
-
pipe(items, Array.get(index), Option.exists(item => pipe(itemToSearchText(item, index), Str.toLowerCase, Str.startsWith(lowerQuery))));
|
|
328
|
-
return pipe(items.length, Array.makeBy(step => wrapIndex(startIndex + step, items.length)), Array.findFirst(isEnabledMatch));
|
|
329
|
-
};
|
|
330
311
|
/** Renders a headless menu with typeahead search, keyboard navigation, and aria-activedescendant focus management. */
|
|
331
312
|
export const view = (config) => {
|
|
332
313
|
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();
|
|
@@ -348,7 +329,7 @@ export const view = (config) => {
|
|
|
348
329
|
DataAttribute('leave', ''),
|
|
349
330
|
DataAttribute('transition', ''),
|
|
350
331
|
]), M.orElse(() => []));
|
|
351
|
-
const isDisabled = (index) =>
|
|
332
|
+
const isDisabled = (index) => Predicate.isNotUndefined(isItemDisabled) &&
|
|
352
333
|
pipe(items, Array.get(index), Option.exists(item => isItemDisabled(item, index)));
|
|
353
334
|
const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(0, 1);
|
|
354
335
|
const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(items.length - 1, -1);
|
|
@@ -388,7 +369,7 @@ export const view = (config) => {
|
|
|
388
369
|
: Option.map(maybeActiveItemIndex, index => toMessage(RequestedItemClick({ index })))), M.whenOr('ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(toMessage(ActivatedItem({
|
|
389
370
|
index: resolveActiveIndex(key),
|
|
390
371
|
activationTrigger: 'Keyboard',
|
|
391
|
-
})))), M.when(
|
|
372
|
+
})))), M.when(isPrintableKey, () => searchForKey(key)), M.orElse(() => Option.none()));
|
|
392
373
|
const handleItemsPointerUp = (screenX, screenY, pointerType, timeStamp) => OptionExt.when(pointerType === 'mouse', toMessage(ReleasedPointerOnItems({ screenX, screenY, timeStamp })));
|
|
393
374
|
const buttonAttributes = [
|
|
394
375
|
Id(`${id}-button`),
|
|
@@ -450,7 +431,6 @@ export const view = (config) => {
|
|
|
450
431
|
return keyed('div')(itemId(id, index), [
|
|
451
432
|
Id(itemId(id, index)),
|
|
452
433
|
Role('menuitem'),
|
|
453
|
-
Tabindex(-1),
|
|
454
434
|
Class(itemConfig.className),
|
|
455
435
|
...(isActiveItem ? [DataAttribute('active', '')] : []),
|
|
456
436
|
...(isDisabledItem
|