foldkit 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -8
- package/dist/fieldValidation/index.d.ts +8 -8
- package/dist/fieldValidation/index.d.ts.map +1 -1
- package/dist/fieldValidation/index.js +5 -4
- package/dist/html/index.d.ts +29 -1
- package/dist/html/index.d.ts.map +1 -1
- package/dist/html/index.js +23 -3
- package/dist/runtime/browserListeners.d.ts.map +1 -1
- package/dist/runtime/browserListeners.js +2 -2
- package/dist/runtime/runtime.d.ts +1 -1
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/urlRequest.d.ts +4 -4
- package/dist/runtime/urlRequest.d.ts.map +1 -1
- package/dist/runtime/urlRequest.js +3 -2
- package/dist/schema/index.d.ts +13 -7
- package/dist/schema/index.d.ts.map +1 -1
- package/dist/schema/index.js +13 -15
- package/dist/schema/public.d.ts +1 -0
- package/dist/schema/public.d.ts.map +1 -1
- package/dist/task/index.d.ts +35 -12
- package/dist/task/index.d.ts.map +1 -1
- package/dist/task/index.js +48 -11
- package/dist/task/public.d.ts +1 -1
- package/dist/task/public.d.ts.map +1 -1
- package/dist/task/public.js +1 -1
- package/dist/ui/dialog/index.d.ts +4 -4
- package/dist/ui/dialog/index.d.ts.map +1 -1
- package/dist/ui/dialog/index.js +4 -4
- package/dist/ui/disclosure/index.d.ts +4 -4
- package/dist/ui/disclosure/index.d.ts.map +1 -1
- package/dist/ui/disclosure/index.js +4 -4
- 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 +4 -0
- package/dist/ui/keyboard.d.ts.map +1 -0
- package/dist/ui/keyboard.js +7 -0
- package/dist/ui/menu/index.d.ts +136 -0
- package/dist/ui/menu/index.d.ts.map +1 -0
- package/dist/ui/menu/index.js +297 -0
- package/dist/ui/menu/public.d.ts +3 -0
- package/dist/ui/menu/public.d.ts.map +1 -0
- package/dist/ui/menu/public.js +1 -0
- package/dist/ui/tabs/index.d.ts +8 -10
- package/dist/ui/tabs/index.d.ts.map +1 -1
- package/dist/ui/tabs/index.js +11 -22
- package/dist/url/index.d.ts +1 -1
- package/package.json +10 -1
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
import { Array, Match as M, Option, Schema as S, String as Str, pipe, } from 'effect';
|
|
2
|
+
import { html } from '../../html';
|
|
3
|
+
import { ts } from '../../schema';
|
|
4
|
+
import { evo } from '../../struct';
|
|
5
|
+
import * as Task from '../../task';
|
|
6
|
+
import { findFirstEnabledIndex, keyToIndex, wrapIndex } from '../keyboard';
|
|
7
|
+
// MODEL
|
|
8
|
+
/** Schema for the activation trigger — whether the user interacted via mouse or keyboard. */
|
|
9
|
+
export const ActivationTrigger = S.Literal('Pointer', 'Keyboard');
|
|
10
|
+
/** Schema for the menu component's state, tracking open/closed status, active item, activation trigger, and typeahead search. */
|
|
11
|
+
export const Model = S.Struct({
|
|
12
|
+
id: S.String,
|
|
13
|
+
isOpen: S.Boolean,
|
|
14
|
+
maybeActiveItemIndex: S.OptionFromSelf(S.Number),
|
|
15
|
+
activationTrigger: ActivationTrigger,
|
|
16
|
+
searchQuery: S.String,
|
|
17
|
+
searchVersion: S.Number,
|
|
18
|
+
maybeLastPointerPosition: S.OptionFromSelf(S.Struct({ screenX: S.Number, screenY: S.Number })),
|
|
19
|
+
});
|
|
20
|
+
// MESSAGE
|
|
21
|
+
/** Sent when the menu opens via button click or keyboard. Contains an optional initial active item index — None for pointer, Some for keyboard. */
|
|
22
|
+
export const Opened = ts('Opened', {
|
|
23
|
+
maybeActiveItemIndex: S.OptionFromSelf(S.Number),
|
|
24
|
+
});
|
|
25
|
+
/** Sent when the menu closes via Escape key or backdrop click. */
|
|
26
|
+
export const Closed = ts('Closed');
|
|
27
|
+
/** Sent when focus leaves the menu items container via Tab key. */
|
|
28
|
+
export const ClosedByTab = ts('ClosedByTab');
|
|
29
|
+
/** Sent when an item is highlighted via arrow keys or mouse hover. Includes activation trigger. */
|
|
30
|
+
export const ItemActivated = ts('ItemActivated', {
|
|
31
|
+
index: S.Number,
|
|
32
|
+
activationTrigger: ActivationTrigger,
|
|
33
|
+
});
|
|
34
|
+
/** Sent when the mouse leaves an enabled item. */
|
|
35
|
+
export const ItemDeactivated = ts('ItemDeactivated');
|
|
36
|
+
/** Sent when an item is selected via Enter, Space, or click. */
|
|
37
|
+
export const ItemSelected = ts('ItemSelected', { index: S.Number });
|
|
38
|
+
/** Sent when a printable character is typed for typeahead search. */
|
|
39
|
+
export const Searched = ts('Searched', {
|
|
40
|
+
key: S.String,
|
|
41
|
+
maybeTargetIndex: S.OptionFromSelf(S.Number),
|
|
42
|
+
});
|
|
43
|
+
/** Sent after the search debounce period to clear the accumulated query. */
|
|
44
|
+
export const SearchCleared = ts('SearchCleared', { version: S.Number });
|
|
45
|
+
/** Sent when the pointer moves over a menu item, carrying screen coordinates for tracked-pointer comparison. */
|
|
46
|
+
export const PointerMovedOverItem = ts('PointerMovedOverItem', {
|
|
47
|
+
index: S.Number,
|
|
48
|
+
screenX: S.Number,
|
|
49
|
+
screenY: S.Number,
|
|
50
|
+
});
|
|
51
|
+
/** Placeholder message used when no action is needed. */
|
|
52
|
+
export const NoOp = ts('NoOp');
|
|
53
|
+
/** Union of all messages the menu component can produce. */
|
|
54
|
+
export const Message = S.Union(Opened, Closed, ClosedByTab, ItemActivated, ItemDeactivated, ItemSelected, PointerMovedOverItem, Searched, SearchCleared, NoOp);
|
|
55
|
+
// INIT
|
|
56
|
+
const SEARCH_DEBOUNCE_MILLISECONDS = 350;
|
|
57
|
+
/** Creates an initial menu model from a config. Defaults to closed with no active item. */
|
|
58
|
+
export const init = (config) => ({
|
|
59
|
+
id: config.id,
|
|
60
|
+
isOpen: false,
|
|
61
|
+
maybeActiveItemIndex: Option.none(),
|
|
62
|
+
activationTrigger: 'Keyboard',
|
|
63
|
+
searchQuery: '',
|
|
64
|
+
searchVersion: 0,
|
|
65
|
+
maybeLastPointerPosition: Option.none(),
|
|
66
|
+
});
|
|
67
|
+
// UPDATE
|
|
68
|
+
const closedModel = (model) => evo(model, {
|
|
69
|
+
isOpen: () => false,
|
|
70
|
+
maybeActiveItemIndex: () => Option.none(),
|
|
71
|
+
activationTrigger: () => 'Keyboard',
|
|
72
|
+
searchQuery: () => '',
|
|
73
|
+
searchVersion: () => 0,
|
|
74
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
75
|
+
});
|
|
76
|
+
const buttonSelector = (id) => `#${id}-button`;
|
|
77
|
+
const itemsSelector = (id) => `#${id}-items`;
|
|
78
|
+
const itemSelector = (id, index) => `#${id}-item-${index}`;
|
|
79
|
+
/** Processes a menu message and returns the next model and commands. */
|
|
80
|
+
export const update = (model, message) => M.value(message).pipe(M.withReturnType(), M.tagsExhaustive({
|
|
81
|
+
Opened: ({ maybeActiveItemIndex }) => [
|
|
82
|
+
evo(model, {
|
|
83
|
+
isOpen: () => true,
|
|
84
|
+
maybeActiveItemIndex: () => maybeActiveItemIndex,
|
|
85
|
+
activationTrigger: () => Option.match(maybeActiveItemIndex, {
|
|
86
|
+
onNone: () => 'Pointer',
|
|
87
|
+
onSome: () => 'Keyboard',
|
|
88
|
+
}),
|
|
89
|
+
searchQuery: () => '',
|
|
90
|
+
searchVersion: () => 0,
|
|
91
|
+
maybeLastPointerPosition: () => Option.none(),
|
|
92
|
+
}),
|
|
93
|
+
[Task.focus(itemsSelector(model.id), () => NoOp())],
|
|
94
|
+
],
|
|
95
|
+
Closed: () => [
|
|
96
|
+
closedModel(model),
|
|
97
|
+
[Task.focus(buttonSelector(model.id), () => NoOp())],
|
|
98
|
+
],
|
|
99
|
+
ClosedByTab: () => [closedModel(model), []],
|
|
100
|
+
ItemActivated: ({ index, activationTrigger }) => [
|
|
101
|
+
evo(model, {
|
|
102
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
103
|
+
activationTrigger: () => activationTrigger,
|
|
104
|
+
}),
|
|
105
|
+
activationTrigger === 'Keyboard'
|
|
106
|
+
? [Task.scrollIntoView(itemSelector(model.id, index), () => NoOp())]
|
|
107
|
+
: [],
|
|
108
|
+
],
|
|
109
|
+
PointerMovedOverItem: ({ index, screenX, screenY }) => {
|
|
110
|
+
const isSamePosition = Option.exists(model.maybeLastPointerPosition, (position) => position.screenX === screenX && position.screenY === screenY);
|
|
111
|
+
if (isSamePosition) {
|
|
112
|
+
return [model, []];
|
|
113
|
+
}
|
|
114
|
+
return [
|
|
115
|
+
evo(model, {
|
|
116
|
+
maybeActiveItemIndex: () => Option.some(index),
|
|
117
|
+
activationTrigger: () => 'Pointer',
|
|
118
|
+
maybeLastPointerPosition: () => Option.some({ screenX, screenY }),
|
|
119
|
+
}),
|
|
120
|
+
[],
|
|
121
|
+
];
|
|
122
|
+
},
|
|
123
|
+
ItemDeactivated: () => model.activationTrigger === 'Pointer'
|
|
124
|
+
? [evo(model, { maybeActiveItemIndex: () => Option.none() }), []]
|
|
125
|
+
: [model, []],
|
|
126
|
+
ItemSelected: () => [
|
|
127
|
+
closedModel(model),
|
|
128
|
+
[Task.focus(buttonSelector(model.id), () => NoOp())],
|
|
129
|
+
],
|
|
130
|
+
Searched: ({ key, maybeTargetIndex }) => {
|
|
131
|
+
const nextSearchQuery = model.searchQuery + key;
|
|
132
|
+
const nextSearchVersion = model.searchVersion + 1;
|
|
133
|
+
return [
|
|
134
|
+
evo(model, {
|
|
135
|
+
searchQuery: () => nextSearchQuery,
|
|
136
|
+
searchVersion: () => nextSearchVersion,
|
|
137
|
+
maybeActiveItemIndex: () => Option.orElse(maybeTargetIndex, () => model.maybeActiveItemIndex),
|
|
138
|
+
}),
|
|
139
|
+
[
|
|
140
|
+
Task.delay(SEARCH_DEBOUNCE_MILLISECONDS, () => SearchCleared({ version: nextSearchVersion })),
|
|
141
|
+
],
|
|
142
|
+
];
|
|
143
|
+
},
|
|
144
|
+
SearchCleared: ({ version }) => {
|
|
145
|
+
if (version !== model.searchVersion) {
|
|
146
|
+
return [model, []];
|
|
147
|
+
}
|
|
148
|
+
return [evo(model, { searchQuery: () => '' }), []];
|
|
149
|
+
},
|
|
150
|
+
NoOp: () => [model, []],
|
|
151
|
+
}));
|
|
152
|
+
export const groupContiguous = (items, toKey) => {
|
|
153
|
+
const tagged = Array.map(items, (item, index) => ({
|
|
154
|
+
key: toKey(item, index),
|
|
155
|
+
item,
|
|
156
|
+
}));
|
|
157
|
+
return Array.chop(tagged, (nonEmpty) => {
|
|
158
|
+
const key = Array.headNonEmpty(nonEmpty).key;
|
|
159
|
+
const [matching, rest] = Array.span(nonEmpty, (tagged) => tagged.key === key);
|
|
160
|
+
return [{ key, items: Array.map(matching, ({ item }) => item) }, rest];
|
|
161
|
+
});
|
|
162
|
+
};
|
|
163
|
+
const itemId = (id, index) => `${id}-item-${index}`;
|
|
164
|
+
/** 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. */
|
|
165
|
+
export const resolveTypeaheadMatch = (items, query, maybeActiveItemIndex, isDisabled, itemToSearchText, isRefinement) => {
|
|
166
|
+
const lowerQuery = Str.toLowerCase(query);
|
|
167
|
+
const offset = isRefinement ? 0 : 1;
|
|
168
|
+
const startIndex = Option.match(maybeActiveItemIndex, {
|
|
169
|
+
onNone: () => 0,
|
|
170
|
+
onSome: (index) => index + offset,
|
|
171
|
+
});
|
|
172
|
+
const isEnabledMatch = (index) => !isDisabled(index) &&
|
|
173
|
+
pipe(items, Array.get(index), Option.exists((item) => pipe(itemToSearchText(item, index), Str.toLowerCase, Str.startsWith(lowerQuery))));
|
|
174
|
+
return pipe(items.length, Array.makeBy((step) => wrapIndex(startIndex + step, items.length)), Array.findFirst(isEnabledMatch));
|
|
175
|
+
};
|
|
176
|
+
/** Renders a headless menu with typeahead search, keyboard navigation, and aria-activedescendant focus management. */
|
|
177
|
+
export const view = (config) => {
|
|
178
|
+
const { div, AriaActiveDescendant, AriaControls, AriaDisabled, AriaExpanded, AriaHasPopup, AriaLabelledBy, Class, DataAttribute, Id, OnBlur, OnClick, OnKeyDownPreventDefault, OnMouseLeave, OnPointerMove, Role, Tabindex, Type, keyed, } = html();
|
|
179
|
+
const { model: { id, isOpen, maybeActiveItemIndex, searchQuery }, toMessage, items, itemToConfig, isItemDisabled, itemToSearchText = (item) => item, isButtonDisabled, buttonContent, buttonClassName, itemsClassName, backdropClassName, className, itemGroupKey, groupToHeading, groupClassName, separatorClassName, } = config;
|
|
180
|
+
const isDisabled = (index) => !!isItemDisabled &&
|
|
181
|
+
pipe(items, Array.get(index), Option.exists((item) => isItemDisabled(item, index)));
|
|
182
|
+
const firstEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(0, 1);
|
|
183
|
+
const lastEnabledIndex = findFirstEnabledIndex(items.length, 0, isDisabled)(items.length - 1, -1);
|
|
184
|
+
const handleButtonKeyDown = (key) => M.value(key).pipe(M.whenOr('Enter', ' ', 'ArrowDown', () => Option.some(toMessage(Opened({
|
|
185
|
+
maybeActiveItemIndex: Option.some(firstEnabledIndex),
|
|
186
|
+
})))), M.when('ArrowUp', () => Option.some(toMessage(Opened({
|
|
187
|
+
maybeActiveItemIndex: Option.some(lastEnabledIndex),
|
|
188
|
+
})))), M.orElse(() => Option.none()));
|
|
189
|
+
const handleButtonClick = () => isOpen
|
|
190
|
+
? toMessage(Closed())
|
|
191
|
+
: toMessage(Opened({ maybeActiveItemIndex: Option.none() }));
|
|
192
|
+
const resolveActiveIndex = keyToIndex('ArrowDown', 'ArrowUp', items.length, Option.getOrElse(maybeActiveItemIndex, () => 0), isDisabled);
|
|
193
|
+
const handleItemsKeyDown = (key) => M.value(key).pipe(M.when('Escape', () => Option.some(toMessage(Closed()))), M.whenOr('Enter', ' ', () => Option.map(maybeActiveItemIndex, (index) => toMessage(ItemSelected({ index })))), M.whenOr('ArrowDown', 'ArrowUp', 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(toMessage(ItemActivated({
|
|
194
|
+
index: resolveActiveIndex(key),
|
|
195
|
+
activationTrigger: 'Keyboard',
|
|
196
|
+
})))), M.when((key) => key.length === 1, () => {
|
|
197
|
+
const nextQuery = searchQuery + key;
|
|
198
|
+
const maybeTargetIndex = resolveTypeaheadMatch(items, nextQuery, maybeActiveItemIndex, isDisabled, itemToSearchText, Str.isNonEmpty(searchQuery));
|
|
199
|
+
return Option.some(toMessage(Searched({ key, maybeTargetIndex })));
|
|
200
|
+
}), M.orElse(() => Option.none()));
|
|
201
|
+
const buttonAttributes = [
|
|
202
|
+
Id(`${id}-button`),
|
|
203
|
+
Type('button'),
|
|
204
|
+
Class(buttonClassName),
|
|
205
|
+
AriaHasPopup('menu'),
|
|
206
|
+
AriaExpanded(isOpen),
|
|
207
|
+
AriaControls(`${id}-items`),
|
|
208
|
+
...(isButtonDisabled
|
|
209
|
+
? [AriaDisabled(true), DataAttribute('disabled', '')]
|
|
210
|
+
: [
|
|
211
|
+
OnKeyDownPreventDefault(handleButtonKeyDown),
|
|
212
|
+
OnClick(handleButtonClick()),
|
|
213
|
+
]),
|
|
214
|
+
...(isOpen ? [DataAttribute('open', '')] : []),
|
|
215
|
+
];
|
|
216
|
+
const maybeActiveDescendant = Option.match(maybeActiveItemIndex, {
|
|
217
|
+
onNone: () => [],
|
|
218
|
+
onSome: (index) => [AriaActiveDescendant(itemId(id, index))],
|
|
219
|
+
});
|
|
220
|
+
const itemsContainerAttributes = [
|
|
221
|
+
Id(`${id}-items`),
|
|
222
|
+
Role('menu'),
|
|
223
|
+
AriaLabelledBy(`${id}-button`),
|
|
224
|
+
...maybeActiveDescendant,
|
|
225
|
+
Tabindex(0),
|
|
226
|
+
Class(itemsClassName),
|
|
227
|
+
OnKeyDownPreventDefault(handleItemsKeyDown),
|
|
228
|
+
OnBlur(toMessage(ClosedByTab())),
|
|
229
|
+
];
|
|
230
|
+
const menuItems = Array.map(items, (item, index) => {
|
|
231
|
+
const isActiveItem = Option.exists(maybeActiveItemIndex, (activeIndex) => activeIndex === index);
|
|
232
|
+
const isDisabledItem = isDisabled(index);
|
|
233
|
+
const itemConfig = itemToConfig(item, {
|
|
234
|
+
isActive: isActiveItem,
|
|
235
|
+
isDisabled: isDisabledItem,
|
|
236
|
+
});
|
|
237
|
+
return keyed('div')(itemId(id, index), [
|
|
238
|
+
Id(itemId(id, index)),
|
|
239
|
+
Role('menuitem'),
|
|
240
|
+
Tabindex(-1),
|
|
241
|
+
Class(itemConfig.className),
|
|
242
|
+
...(isActiveItem ? [DataAttribute('active', '')] : []),
|
|
243
|
+
...(isDisabledItem
|
|
244
|
+
? [AriaDisabled(true), DataAttribute('disabled', '')]
|
|
245
|
+
: [
|
|
246
|
+
OnClick(toMessage(ItemSelected({ index }))),
|
|
247
|
+
OnPointerMove((screenX, screenY) => toMessage(PointerMovedOverItem({ index, screenX, screenY }))),
|
|
248
|
+
OnMouseLeave(toMessage(ItemDeactivated())),
|
|
249
|
+
]),
|
|
250
|
+
], [itemConfig.content]);
|
|
251
|
+
});
|
|
252
|
+
const renderGroupedItems = () => {
|
|
253
|
+
if (!itemGroupKey) {
|
|
254
|
+
return menuItems;
|
|
255
|
+
}
|
|
256
|
+
const segments = groupContiguous(menuItems, (_, index) => Array.get(items, index).pipe(Option.match({
|
|
257
|
+
onNone: () => '',
|
|
258
|
+
onSome: (item) => itemGroupKey(item, index),
|
|
259
|
+
})));
|
|
260
|
+
return Array.flatMap(segments, (segment, segmentIndex) => {
|
|
261
|
+
const maybeHeading = Option.fromNullable(groupToHeading && groupToHeading(segment.key));
|
|
262
|
+
const headingId = `${id}-heading-${segment.key}`;
|
|
263
|
+
const headingElement = Option.match(maybeHeading, {
|
|
264
|
+
onNone: () => [],
|
|
265
|
+
onSome: (heading) => [
|
|
266
|
+
keyed('div')(headingId, [Id(headingId), Role('presentation'), Class(heading.className)], [heading.content]),
|
|
267
|
+
],
|
|
268
|
+
});
|
|
269
|
+
const groupContent = [...headingElement, ...segment.items];
|
|
270
|
+
const groupElement = keyed('div')(`${id}-group-${segment.key}`, [
|
|
271
|
+
Role('group'),
|
|
272
|
+
...(Option.isSome(maybeHeading) ? [AriaLabelledBy(headingId)] : []),
|
|
273
|
+
...(groupClassName ? [Class(groupClassName)] : []),
|
|
274
|
+
], groupContent);
|
|
275
|
+
const separator = segmentIndex > 0 && separatorClassName
|
|
276
|
+
? [
|
|
277
|
+
keyed('div')(`${id}-separator-${segmentIndex}`, [Role('separator'), Class(separatorClassName)], []),
|
|
278
|
+
]
|
|
279
|
+
: [];
|
|
280
|
+
return [...separator, groupElement];
|
|
281
|
+
});
|
|
282
|
+
};
|
|
283
|
+
const backdrop = keyed('div')(`${id}-backdrop`, [Class(backdropClassName), OnClick(toMessage(Closed()))], []);
|
|
284
|
+
const renderedItems = renderGroupedItems();
|
|
285
|
+
const openContent = [
|
|
286
|
+
backdrop,
|
|
287
|
+
keyed('div')(`${id}-items-container`, itemsContainerAttributes, renderedItems),
|
|
288
|
+
];
|
|
289
|
+
const wrapperAttributes = [
|
|
290
|
+
...(className ? [Class(className)] : []),
|
|
291
|
+
...(isOpen ? [DataAttribute('open', '')] : []),
|
|
292
|
+
];
|
|
293
|
+
return div(wrapperAttributes, [
|
|
294
|
+
keyed('button')(`${id}-button`, buttonAttributes, [buttonContent]),
|
|
295
|
+
...(isOpen ? openContent : []),
|
|
296
|
+
]);
|
|
297
|
+
};
|
|
@@ -0,0 +1,3 @@
|
|
|
1
|
+
export { init, update, view, Model, Message } from './index';
|
|
2
|
+
export type { ActivationTrigger, Opened, Closed, ClosedByTab, ItemActivated, ItemDeactivated, ItemSelected, PointerMovedOverItem, Searched, SearchCleared, NoOp, InitConfig, ViewConfig, ItemConfig, GroupHeading, } from './index';
|
|
3
|
+
//# sourceMappingURL=public.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../../src/ui/menu/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,IAAI,EAAE,MAAM,EAAE,IAAI,EAAE,KAAK,EAAE,OAAO,EAAE,MAAM,SAAS,CAAA;AAE5D,YAAY,EACV,iBAAiB,EACjB,MAAM,EACN,MAAM,EACN,WAAW,EACX,aAAa,EACb,eAAe,EACf,YAAY,EACZ,oBAAoB,EACpB,QAAQ,EACR,aAAa,EACb,IAAI,EACJ,UAAU,EACV,UAAU,EACV,UAAU,EACV,YAAY,GACb,MAAM,SAAS,CAAA"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { init, update, view, Model, Message } from './index';
|
package/dist/ui/tabs/index.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Schema as S } from 'effect';
|
|
2
2
|
import type { Html, TagName } from '../../html';
|
|
3
3
|
import type { Command } from '../../runtime/runtime';
|
|
4
|
+
export { wrapIndex, findFirstEnabledIndex, keyToIndex } from '../keyboard';
|
|
4
5
|
/** Controls the tab list layout direction and which arrow keys navigate between tabs. */
|
|
5
6
|
export declare const Orientation: S.Literal<["Horizontal", "Vertical"]>;
|
|
6
7
|
export type Orientation = typeof Orientation.Type;
|
|
@@ -17,21 +18,21 @@ export declare const Model: S.Struct<{
|
|
|
17
18
|
}>;
|
|
18
19
|
export type Model = typeof Model.Type;
|
|
19
20
|
/** Sent when a tab is selected via click or keyboard. Updates both the active and focused indices. */
|
|
20
|
-
export declare const TabSelected:
|
|
21
|
+
export declare const TabSelected: import("../../schema").CallableTaggedStruct<"TabSelected", {
|
|
21
22
|
index: typeof S.Number;
|
|
22
23
|
}>;
|
|
23
24
|
/** Sent when a tab receives keyboard focus in `Manual` mode without being activated. */
|
|
24
|
-
export declare const TabFocused:
|
|
25
|
+
export declare const TabFocused: import("../../schema").CallableTaggedStruct<"TabFocused", {
|
|
25
26
|
index: typeof S.Number;
|
|
26
27
|
}>;
|
|
27
28
|
/** Placeholder message used when no action is needed, such as after a focus command completes. */
|
|
28
|
-
export declare const NoOp:
|
|
29
|
+
export declare const NoOp: import("../../schema").CallableTaggedStruct<"NoOp", {}>;
|
|
29
30
|
/** Union of all messages the tabs component can produce. */
|
|
30
|
-
export declare const Message: S.Union<[
|
|
31
|
+
export declare const Message: S.Union<[import("../../schema").CallableTaggedStruct<"TabSelected", {
|
|
31
32
|
index: typeof S.Number;
|
|
32
|
-
}>,
|
|
33
|
+
}>, import("../../schema").CallableTaggedStruct<"TabFocused", {
|
|
33
34
|
index: typeof S.Number;
|
|
34
|
-
}>,
|
|
35
|
+
}>, import("../../schema").CallableTaggedStruct<"NoOp", {}>]>;
|
|
35
36
|
export type TabSelected = typeof TabSelected.Type;
|
|
36
37
|
export type TabFocused = typeof TabFocused.Type;
|
|
37
38
|
export type NoOp = typeof NoOp.Type;
|
|
@@ -47,9 +48,6 @@ export type InitConfig = Readonly<{
|
|
|
47
48
|
export declare const init: (config: InitConfig) => Model;
|
|
48
49
|
/** Processes a tabs message and returns the next model and commands. */
|
|
49
50
|
export declare const update: (model: Model, message: Message) => [Model, ReadonlyArray<Command<Message>>];
|
|
50
|
-
export declare const wrapIndex: (index: number, length: number) => number;
|
|
51
|
-
export declare const findFirstEnabledIndex: (tabCount: number, focusedIndex: number, isDisabled: (index: number) => boolean) => (startIndex: number, direction: 1 | -1) => number;
|
|
52
|
-
export declare const keyToIndex: (nextKey: string, previousKey: string, tabCount: number, focusedIndex: number, isDisabled: (index: number) => boolean) => ((key: string) => number);
|
|
53
51
|
/** Configuration for an individual tab's button and panel content. */
|
|
54
52
|
export type TabConfig = Readonly<{
|
|
55
53
|
buttonClassName: string;
|
|
@@ -60,7 +58,7 @@ export type TabConfig = Readonly<{
|
|
|
60
58
|
/** Configuration for rendering a tab group with `view`. */
|
|
61
59
|
export type ViewConfig<Message, Tab extends string> = Readonly<{
|
|
62
60
|
model: Model;
|
|
63
|
-
toMessage: (message: TabSelected | TabFocused
|
|
61
|
+
toMessage: (message: TabSelected | TabFocused) => Message;
|
|
64
62
|
tabs: ReadonlyArray<Tab>;
|
|
65
63
|
tabToConfig: (tab: Tab, context: {
|
|
66
64
|
isActive: boolean;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ui/tabs/index.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../../src/ui/tabs/index.ts"],"names":[],"mappings":"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"}
|
package/dist/ui/tabs/index.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
|
-
import { Array, Match as M, Option,
|
|
1
|
+
import { Array, Match as M, Option, Schema as S, String, pipe } from 'effect';
|
|
2
2
|
import { html } from '../../html';
|
|
3
3
|
import { ts } from '../../schema';
|
|
4
4
|
import { evo } from '../../struct';
|
|
5
5
|
import * as Task from '../../task';
|
|
6
|
+
import { keyToIndex } from '../keyboard';
|
|
7
|
+
export { wrapIndex, findFirstEnabledIndex, keyToIndex } from '../keyboard';
|
|
6
8
|
// MODEL
|
|
7
9
|
/** Controls the tab list layout direction and which arrow keys navigate between tabs. */
|
|
8
10
|
export const Orientation = S.Literal('Horizontal', 'Vertical');
|
|
@@ -46,30 +48,23 @@ export const update = (model, message) => M.value(message).pipe(M.withReturnType
|
|
|
46
48
|
activeIndex: () => index,
|
|
47
49
|
focusedIndex: () => index,
|
|
48
50
|
}),
|
|
49
|
-
[Task.focus(tabSelector, () => NoOp
|
|
51
|
+
[Task.focus(tabSelector, () => NoOp())],
|
|
50
52
|
];
|
|
51
53
|
},
|
|
52
54
|
TabFocused: ({ index }) => {
|
|
53
55
|
const tabSelector = `#${tabId(model.id, index)}`;
|
|
54
56
|
return [
|
|
55
57
|
evo(model, { focusedIndex: () => index }),
|
|
56
|
-
[Task.focus(tabSelector, () => NoOp
|
|
58
|
+
[Task.focus(tabSelector, () => NoOp())],
|
|
57
59
|
];
|
|
58
60
|
},
|
|
59
61
|
NoOp: () => [model, []],
|
|
60
62
|
}));
|
|
61
|
-
// KEYBOARD
|
|
62
|
-
export const wrapIndex = (index, length) => ((index % length) + length) % length;
|
|
63
|
-
export const findFirstEnabledIndex = (tabCount, focusedIndex, isDisabled) => (startIndex, direction) => pipe(tabCount, Array.makeBy((step) => wrapIndex(startIndex + step * direction, tabCount)), Array.findFirst(Predicate.not(isDisabled)), Option.getOrElse(() => focusedIndex));
|
|
64
|
-
export const keyToIndex = (nextKey, previousKey, tabCount, focusedIndex, isDisabled) => {
|
|
65
|
-
const find = findFirstEnabledIndex(tabCount, focusedIndex, isDisabled);
|
|
66
|
-
return (key) => M.value(key).pipe(M.when(nextKey, () => find(focusedIndex + 1, 1)), M.when(previousKey, () => find(focusedIndex - 1, -1)), M.whenOr('Home', 'PageUp', () => find(0, 1)), M.whenOr('End', 'PageDown', () => find(tabCount - 1, -1)), M.orElse(() => focusedIndex));
|
|
67
|
-
};
|
|
68
63
|
const tabPanelId = (id, index) => `${id}-panel-${index}`;
|
|
69
64
|
const tabId = (id, index) => `${id}-tab-${index}`;
|
|
70
65
|
/** Renders a headless tab group with accessible ARIA roles, roving tabindex, and keyboard navigation. */
|
|
71
66
|
export const view = (config) => {
|
|
72
|
-
const { div, empty, AriaControls, AriaDisabled, AriaLabelledBy, AriaOrientation, AriaSelected, Class, DataAttribute, Disabled, Hidden, Id, OnClick,
|
|
67
|
+
const { div, empty, AriaControls, AriaDisabled, AriaLabelledBy, AriaOrientation, AriaSelected, Class, DataAttribute, Disabled, Hidden, Id, OnClick, OnKeyDownPreventDefault, Role, Tabindex, Type, keyed, } = html();
|
|
73
68
|
const { model, model: { id, orientation, activationMode, focusedIndex }, toMessage, tabs, tabToConfig, isTabDisabled, persistPanels, tabListElement = 'div', tabElement = 'button', panelElement = 'div', className, tabListClassName, } = config;
|
|
74
69
|
const isDisabled = (index) => !!isTabDisabled &&
|
|
75
70
|
pipe(tabs, Array.get(index), Option.exists((tab) => isTabDisabled(tab, index)));
|
|
@@ -81,15 +76,9 @@ export const view = (config) => {
|
|
|
81
76
|
previousKey: 'ArrowUp',
|
|
82
77
|
})), M.exhaustive);
|
|
83
78
|
const resolveKeyIndex = keyToIndex(nextKey, previousKey, tabs.length, focusedIndex, isDisabled);
|
|
84
|
-
const handleAutomaticKeyDown = (key) =>
|
|
85
|
-
|
|
86
|
-
M.value(
|
|
87
|
-
const handleManualKeyDown = (key) =>
|
|
88
|
-
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
89
|
-
M.value(key).pipe(M.whenOr(nextKey, previousKey, 'Home', 'End', 'PageUp', 'PageDown', () => toMessage(TabFocused.make({ index: resolveKeyIndex(key) }))), M.whenOr('Enter', ' ', () => toMessage(TabSelected.make({ index: focusedIndex }))), M.orElse(() => toMessage(NoOp.make())));
|
|
90
|
-
const handleKeyDown = (key) =>
|
|
91
|
-
/* eslint-disable-next-line @typescript-eslint/consistent-type-assertions */
|
|
92
|
-
M.value(activationMode).pipe(M.when('Automatic', () => handleAutomaticKeyDown(key)), M.when('Manual', () => handleManualKeyDown(key)), M.exhaustive);
|
|
79
|
+
const handleAutomaticKeyDown = (key) => M.value(key).pipe(M.whenOr(nextKey, previousKey, 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(toMessage(TabSelected({ index: resolveKeyIndex(key) })))), M.whenOr('Enter', ' ', () => Option.some(toMessage(TabSelected({ index: focusedIndex })))), M.orElse(() => Option.none()));
|
|
80
|
+
const handleManualKeyDown = (key) => M.value(key).pipe(M.whenOr(nextKey, previousKey, 'Home', 'End', 'PageUp', 'PageDown', () => Option.some(toMessage(TabFocused({ index: resolveKeyIndex(key) })))), M.whenOr('Enter', ' ', () => Option.some(toMessage(TabSelected({ index: focusedIndex })))), M.orElse(() => Option.none()));
|
|
81
|
+
const handleKeyDown = (key) => M.value(activationMode).pipe(M.when('Automatic', () => handleAutomaticKeyDown(key)), M.when('Manual', () => handleManualKeyDown(key)), M.exhaustive);
|
|
93
82
|
const tabButtons = Array.map(tabs, (tab, index) => {
|
|
94
83
|
const isActive = index === model.activeIndex;
|
|
95
84
|
const isFocused = index === focusedIndex;
|
|
@@ -106,8 +95,8 @@ export const view = (config) => {
|
|
|
106
95
|
...(isActive ? [DataAttribute('selected', '')] : []),
|
|
107
96
|
...(isTabDisabledAtIndex
|
|
108
97
|
? [Disabled(true), AriaDisabled(true), DataAttribute('disabled', '')]
|
|
109
|
-
: [OnClick(toMessage(TabSelected
|
|
110
|
-
|
|
98
|
+
: [OnClick(toMessage(TabSelected({ index })))]),
|
|
99
|
+
OnKeyDownPreventDefault(handleKeyDown),
|
|
111
100
|
], [tabConfig.buttonContent]);
|
|
112
101
|
});
|
|
113
102
|
const allPanels = Array.map(tabs, (tab, index) => {
|
package/dist/url/index.d.ts
CHANGED
|
@@ -11,11 +11,11 @@ export declare const Url: S.Struct<{
|
|
|
11
11
|
export type Url = typeof Url.Type;
|
|
12
12
|
/** Parses a URL string into a `Url`, returning `Option.None` if invalid. */
|
|
13
13
|
export declare const fromString: (str: string) => Option.Option<{
|
|
14
|
+
readonly search: Option.Option<string>;
|
|
14
15
|
readonly protocol: string;
|
|
15
16
|
readonly host: string;
|
|
16
17
|
readonly port: Option.Option<string>;
|
|
17
18
|
readonly pathname: string;
|
|
18
|
-
readonly search: Option.Option<string>;
|
|
19
19
|
readonly hash: Option.Option<string>;
|
|
20
20
|
}>;
|
|
21
21
|
/** Serializes a `Url` back to a string. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "foldkit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Elm-inspired UI framework powered by Effect",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -43,10 +43,18 @@
|
|
|
43
43
|
"types": "./dist/ui/index.d.ts",
|
|
44
44
|
"import": "./dist/ui/index.js"
|
|
45
45
|
},
|
|
46
|
+
"./ui/dialog": {
|
|
47
|
+
"types": "./dist/ui/dialog/public.d.ts",
|
|
48
|
+
"import": "./dist/ui/dialog/public.js"
|
|
49
|
+
},
|
|
46
50
|
"./ui/disclosure": {
|
|
47
51
|
"types": "./dist/ui/disclosure/public.d.ts",
|
|
48
52
|
"import": "./dist/ui/disclosure/public.js"
|
|
49
53
|
},
|
|
54
|
+
"./ui/menu": {
|
|
55
|
+
"types": "./dist/ui/menu/public.d.ts",
|
|
56
|
+
"import": "./dist/ui/menu/public.js"
|
|
57
|
+
},
|
|
50
58
|
"./ui/tabs": {
|
|
51
59
|
"types": "./dist/ui/tabs/public.d.ts",
|
|
52
60
|
"import": "./dist/ui/tabs/public.js"
|
|
@@ -98,6 +106,7 @@
|
|
|
98
106
|
"clean": "rimraf dist *.tsbuildinfo",
|
|
99
107
|
"build": "pnpm run clean && tsc -b",
|
|
100
108
|
"watch": "tsc -b --watch",
|
|
109
|
+
"lint": "eslint src --ignore-pattern '**/*.test.ts'",
|
|
101
110
|
"typecheck": "tsc -b --noEmit",
|
|
102
111
|
"test": "vitest run",
|
|
103
112
|
"docs": "typedoc"
|