foldkit 0.160.0 → 0.161.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/calendar/index.d.ts +1 -1
- package/dist/calendar/index.d.ts.map +1 -1
- package/dist/calendar/index.js +1 -1
- package/dist/calendar/locale.d.ts +219 -10
- package/dist/calendar/locale.d.ts.map +1 -1
- package/dist/calendar/locale.js +166 -82
- package/dist/calendar/public.d.ts +1 -1
- package/dist/calendar/public.d.ts.map +1 -1
- package/dist/calendar/public.js +1 -1
- package/dist/command/index.d.ts +9 -12
- package/dist/command/index.d.ts.map +1 -1
- package/dist/command/index.js +9 -12
- package/dist/devTools/protocol.d.ts +9 -1
- package/dist/devTools/protocol.d.ts.map +1 -1
- package/dist/devTools/protocol.js +2 -1
- package/dist/devTools/serialize.d.ts.map +1 -1
- package/dist/devTools/serialize.js +1 -0
- package/dist/devTools/store.d.ts +3 -0
- package/dist/devTools/store.d.ts.map +1 -1
- package/dist/devTools/store.js +24 -0
- package/dist/dom/dialogRuntime.d.ts +9 -0
- package/dist/dom/dialogRuntime.d.ts.map +1 -0
- package/dist/dom/dialogRuntime.js +3 -0
- package/dist/dom/dom.d.ts +22 -11
- package/dist/dom/dom.d.ts.map +1 -1
- package/dist/dom/dom.js +196 -18
- package/dist/dom/inert.d.ts +9 -0
- package/dist/dom/inert.d.ts.map +1 -1
- package/dist/dom/inert.js +74 -2
- package/dist/managedResource/managedResource.d.ts +44 -2
- package/dist/managedResource/managedResource.d.ts.map +1 -1
- package/dist/managedResource/managedResource.js +40 -10
- package/dist/runtime/devToolsIntegration.d.ts +8 -3
- package/dist/runtime/devToolsIntegration.d.ts.map +1 -1
- package/dist/runtime/devToolsIntegration.js +20 -3
- package/dist/runtime/runtime.d.ts.map +1 -1
- package/dist/runtime/runtime.js +54 -12
- package/dist/subscription/fromEvent.d.ts +103 -28
- package/dist/subscription/fromEvent.d.ts.map +1 -1
- package/dist/subscription/fromEvent.js +48 -27
- package/dist/subscription/keyboardShortcuts.d.ts +110 -0
- package/dist/subscription/keyboardShortcuts.d.ts.map +1 -0
- package/dist/subscription/keyboardShortcuts.js +444 -0
- package/dist/subscription/public.d.ts +3 -1
- package/dist/subscription/public.d.ts.map +1 -1
- package/dist/subscription/public.js +1 -0
- package/dist/subscription/subscription.d.ts +61 -1
- package/dist/subscription/subscription.d.ts.map +1 -1
- package/dist/subscription/subscription.js +51 -8
- package/dist/update/public.d.ts +1 -1
- package/dist/update/public.d.ts.map +1 -1
- package/dist/update/public.js +1 -1
- package/dist/update/update.d.ts +189 -1
- package/dist/update/update.d.ts.map +1 -1
- package/dist/update/update.js +178 -16
- package/package.json +1 -1
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Duration, Stream } from 'effect';
|
|
2
|
+
/** Whether a shortcut may fire when its keyboard event comes from an editable element. */
|
|
3
|
+
export type WhileTyping = 'Suppress' | 'Allow';
|
|
4
|
+
/** A single key press or a sequence of two or more key presses. */
|
|
5
|
+
export type KeyboardShortcut = string | Readonly<[string, string, ...Array<string>]>;
|
|
6
|
+
type BindingBase<Message> = Readonly<{
|
|
7
|
+
shortcut: KeyboardShortcut;
|
|
8
|
+
toMessage: (event: KeyboardEvent) => Message;
|
|
9
|
+
isEnabled?: boolean;
|
|
10
|
+
whileTyping?: WhileTyping;
|
|
11
|
+
preventDefault?: boolean;
|
|
12
|
+
}>;
|
|
13
|
+
/**
|
|
14
|
+
* One entry in a {@link keyboardShortcuts} binding table.
|
|
15
|
+
*
|
|
16
|
+
* A string describes one key press, such as `'/'`, `'Escape'`, or `'Mod+K'`.
|
|
17
|
+
* An array describes a sequence of at least two presses, such as
|
|
18
|
+
* `['G', 'H']` or `['G', 'Shift+G']`.
|
|
19
|
+
*/
|
|
20
|
+
export type KeyboardShortcutBinding<Message> = BindingBase<Message> & (Readonly<{
|
|
21
|
+
shortcut: string;
|
|
22
|
+
whenRepeated?: 'Ignore' | 'Allow';
|
|
23
|
+
}> | Readonly<{
|
|
24
|
+
shortcut: Readonly<[string, string, ...Array<string>]>;
|
|
25
|
+
whenRepeated?: never;
|
|
26
|
+
}>);
|
|
27
|
+
type ModKey = 'Control' | 'Meta';
|
|
28
|
+
/** Configuration for the {@link keyboardShortcuts} Stream helper. */
|
|
29
|
+
export type KeyboardShortcutsConfig<Message> = Readonly<{
|
|
30
|
+
bindings: ReadonlyArray<KeyboardShortcutBinding<Message>>;
|
|
31
|
+
target?: EventTarget | (() => EventTarget);
|
|
32
|
+
modKey?: ModKey;
|
|
33
|
+
sequenceTimeout?: Duration.Input;
|
|
34
|
+
}>;
|
|
35
|
+
/**
|
|
36
|
+
* Build a Stream that turns declarative keyboard shortcuts into Messages.
|
|
37
|
+
*
|
|
38
|
+
* A string shortcut describes one key press. Modifiers are joined with `+`:
|
|
39
|
+
* `'Mod+K'`, `'Control+Shift+P'`, or `'Alt+ArrowDown'`. The supported modifiers
|
|
40
|
+
* are `Mod`, `Control`, `Meta`, `Alt`, and `Shift`. `Mod` resolves to Meta on
|
|
41
|
+
* Apple platforms and Control elsewhere; `modKey` can override that choice.
|
|
42
|
+
* Matching uses `KeyboardEvent.key`, case-insensitively, after the active
|
|
43
|
+
* keyboard layout has been applied. Use `Space` and `Plus` for those keys.
|
|
44
|
+
*
|
|
45
|
+
* An array describes an ordered sequence of two or more presses. Every press
|
|
46
|
+
* uses the same grammar, so `['G', 'Shift+G']` is valid. Sequences reset after
|
|
47
|
+
* one second by default; `sequenceTimeout` accepts any Effect Duration input.
|
|
48
|
+
* Modifier-only events and repeated keydowns do not advance a sequence.
|
|
49
|
+
*
|
|
50
|
+
* Shortcuts are suppressed by default when the event's composed path contains
|
|
51
|
+
* an `input`, `textarea`, `select`, or contenteditable element. Set
|
|
52
|
+
* `whileTyping` to `'Allow'` for a binding that must work there. Events emitted
|
|
53
|
+
* during IME composition are always ignored. Repeated keydowns are ignored for
|
|
54
|
+
* one-press shortcuts unless `whenRepeated` is `'Allow'`. An event another
|
|
55
|
+
* handler already canceled is ignored and clears any sequence in progress.
|
|
56
|
+
*
|
|
57
|
+
* Matched key presses call `preventDefault()` before dispatching. For a
|
|
58
|
+
* sequence, that policy applies to every matched press. Set `preventDefault`
|
|
59
|
+
* to `false` to opt out. Sequences sharing a prefix must use the same policy.
|
|
60
|
+
* Duplicate bindings and a complete shortcut that is also a sequence prefix
|
|
61
|
+
* are rejected when the Stream is created.
|
|
62
|
+
*
|
|
63
|
+
* This helper returns a Stream, not a complete Subscription entry. Use
|
|
64
|
+
* `Subscription.persistent` for a fixed table. When availability depends on
|
|
65
|
+
* the Model that owns the entry, build it inside `dependenciesToStream` and
|
|
66
|
+
* derive each binding's `isEnabled` from the dependency record. A dependency
|
|
67
|
+
* change opens a new Stream scope and resets any sequence in progress. If a
|
|
68
|
+
* parent owns a condition for a lifted child, declare the table at that parent
|
|
69
|
+
* or put shortcuts with different parent-owned lifetimes in separate child
|
|
70
|
+
* entries so `Subscription.lift` can gate them individually. If the meaning
|
|
71
|
+
* of a key depends on the Model, dispatch a factual key Message and decide
|
|
72
|
+
* what it means in update instead of reading the Model from `toMessage`.
|
|
73
|
+
*
|
|
74
|
+
* @example
|
|
75
|
+
* ```typescript
|
|
76
|
+
* const subscriptions = Subscription.make<Model, Message>()(entry => ({
|
|
77
|
+
* shortcuts: entry(
|
|
78
|
+
* { isPaletteOpen: Schema.Boolean },
|
|
79
|
+
* {
|
|
80
|
+
* modelToDependencies: model => ({
|
|
81
|
+
* isPaletteOpen: model.paletteState._tag === 'Open',
|
|
82
|
+
* }),
|
|
83
|
+
* dependenciesToStream: ({ isPaletteOpen }) =>
|
|
84
|
+
* Subscription.keyboardShortcuts<Message>({
|
|
85
|
+
* bindings: [
|
|
86
|
+
* {
|
|
87
|
+
* shortcut: 'Escape',
|
|
88
|
+
* isEnabled: isPaletteOpen,
|
|
89
|
+
* whileTyping: 'Allow',
|
|
90
|
+
* toMessage: () => Message.PressedEscape(),
|
|
91
|
+
* },
|
|
92
|
+
* {
|
|
93
|
+
* shortcut: 'Mod+K',
|
|
94
|
+
* whileTyping: 'Allow',
|
|
95
|
+
* toMessage: () => Message.PressedSearchShortcut(),
|
|
96
|
+
* },
|
|
97
|
+
* {
|
|
98
|
+
* shortcut: ['G', 'L'],
|
|
99
|
+
* toMessage: () => Message.PressedListShortcut(),
|
|
100
|
+
* },
|
|
101
|
+
* ],
|
|
102
|
+
* }),
|
|
103
|
+
* },
|
|
104
|
+
* ),
|
|
105
|
+
* }))
|
|
106
|
+
* ```
|
|
107
|
+
*/
|
|
108
|
+
export declare const keyboardShortcuts: <Message>(config: KeyboardShortcutsConfig<Message>) => Stream.Stream<Message>;
|
|
109
|
+
export {};
|
|
110
|
+
//# sourceMappingURL=keyboardShortcuts.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"keyboardShortcuts.d.ts","sourceRoot":"","sources":["../../src/subscription/keyboardShortcuts.ts"],"names":[],"mappings":"AAAA,OAAO,EAEL,QAAQ,EAOR,MAAM,EAGP,MAAM,QAAQ,CAAA;AAEf,0FAA0F;AAC1F,MAAM,MAAM,WAAW,GAAG,UAAU,GAAG,OAAO,CAAA;AAE9C,mEAAmE;AACnE,MAAM,MAAM,gBAAgB,GACxB,MAAM,GACN,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;AAEhD,KAAK,WAAW,CAAC,OAAO,IAAI,QAAQ,CAAC;IACnC,QAAQ,EAAE,gBAAgB,CAAA;IAC1B,SAAS,EAAE,CAAC,KAAK,EAAE,aAAa,KAAK,OAAO,CAAA;IAC5C,SAAS,CAAC,EAAE,OAAO,CAAA;IACnB,WAAW,CAAC,EAAE,WAAW,CAAA;IACzB,cAAc,CAAC,EAAE,OAAO,CAAA;CACzB,CAAC,CAAA;AAEF;;;;;;GAMG;AACH,MAAM,MAAM,uBAAuB,CAAC,OAAO,IAAI,WAAW,CAAC,OAAO,CAAC,GACjE,CACI,QAAQ,CAAC;IACP,QAAQ,EAAE,MAAM,CAAA;IAChB,YAAY,CAAC,EAAE,QAAQ,GAAG,OAAO,CAAA;CAClC,CAAC,GACF,QAAQ,CAAC;IACP,QAAQ,EAAE,QAAQ,CAAC,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,KAAK,CAAC,MAAM,CAAC,CAAC,CAAC,CAAA;IACtD,YAAY,CAAC,EAAE,KAAK,CAAA;CACrB,CAAC,CACL,CAAA;AAEH,KAAK,MAAM,GAAG,SAAS,GAAG,MAAM,CAAA;AAEhC,qEAAqE;AACrE,MAAM,MAAM,uBAAuB,CAAC,OAAO,IAAI,QAAQ,CAAC;IACtD,QAAQ,EAAE,aAAa,CAAC,uBAAuB,CAAC,OAAO,CAAC,CAAC,CAAA;IACzD,MAAM,CAAC,EAAE,WAAW,GAAG,CAAC,MAAM,WAAW,CAAC,CAAA;IAC1C,MAAM,CAAC,EAAE,MAAM,CAAA;IACf,eAAe,CAAC,EAAE,QAAQ,CAAC,KAAK,CAAA;CACjC,CAAC,CAAA;AA8sBF;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAwEG;AACH,eAAO,MAAM,iBAAiB,GAAI,OAAO,UAC/B,uBAAuB,CAAC,OAAO,CAAC,KACvC,MAAM,CAAC,MAAM,CAAC,OAAO,CACwC,CAAA"}
|
|
@@ -0,0 +1,444 @@
|
|
|
1
|
+
import { Array, Duration, Effect, Match, Number, Option, Predicate, Queue, Stream, String, pipe, } from 'effect';
|
|
2
|
+
const DEFAULT_SEQUENCE_TIMEOUT = Duration.seconds(1);
|
|
3
|
+
const APPLE_PLATFORM_PATTERN = /Mac|iPhone|iPad|iPod/;
|
|
4
|
+
const MODIFIER_KEYS = [
|
|
5
|
+
'alt',
|
|
6
|
+
'altgraph',
|
|
7
|
+
'capslock',
|
|
8
|
+
'control',
|
|
9
|
+
'fn',
|
|
10
|
+
'fnlock',
|
|
11
|
+
'hyper',
|
|
12
|
+
'meta',
|
|
13
|
+
'numlock',
|
|
14
|
+
'os',
|
|
15
|
+
'scrolllock',
|
|
16
|
+
'shift',
|
|
17
|
+
'super',
|
|
18
|
+
'symbol',
|
|
19
|
+
'symbollock',
|
|
20
|
+
];
|
|
21
|
+
const SHORTCUT_MODIFIERS = [
|
|
22
|
+
'alt',
|
|
23
|
+
'control',
|
|
24
|
+
'meta',
|
|
25
|
+
'mod',
|
|
26
|
+
'shift',
|
|
27
|
+
];
|
|
28
|
+
const NON_CANONICAL_MODIFIERS = [
|
|
29
|
+
'cmd',
|
|
30
|
+
'command',
|
|
31
|
+
'ctrl',
|
|
32
|
+
'option',
|
|
33
|
+
];
|
|
34
|
+
const normalizeKey = (key) => {
|
|
35
|
+
if (key === ' ') {
|
|
36
|
+
return key;
|
|
37
|
+
}
|
|
38
|
+
const normalized = pipe(key, String.trim, String.toLowerCase);
|
|
39
|
+
return Match.value(normalized).pipe(Match.withReturnType(), Match.when('space', () => ' '), Match.when('plus', () => '+'), Match.orElse(value => value));
|
|
40
|
+
};
|
|
41
|
+
const displayKey = (key) => Match.value(key).pipe(Match.withReturnType(), Match.when(' ', () => 'space'), Match.when('+', () => 'plus'), Match.orElse(value => value));
|
|
42
|
+
const identifierPartWhen = (isIncluded, identifier) => Option.liftPredicate(identifier, () => isIncluded);
|
|
43
|
+
const pressIdentifier = (press, modKey) => {
|
|
44
|
+
const isModUnresolved = press.isModRequired && modKey === undefined;
|
|
45
|
+
const isControlRequired = press.isControlRequired || (press.isModRequired && modKey === 'Control');
|
|
46
|
+
const isMetaRequired = press.isMetaRequired || (press.isModRequired && modKey === 'Meta');
|
|
47
|
+
return pipe([
|
|
48
|
+
identifierPartWhen(isModUnresolved, 'mod'),
|
|
49
|
+
identifierPartWhen(isControlRequired, 'control'),
|
|
50
|
+
identifierPartWhen(isMetaRequired, 'meta'),
|
|
51
|
+
identifierPartWhen(press.isAltRequired, 'alt'),
|
|
52
|
+
identifierPartWhen(press.isShiftRequired, 'shift'),
|
|
53
|
+
Option.some(displayKey(press.key)),
|
|
54
|
+
], Array.getSomes, Array.join('+'));
|
|
55
|
+
};
|
|
56
|
+
const throwInvalidShortcut = (shortcut, reason) => {
|
|
57
|
+
throw new Error(`Invalid keyboard shortcut "${shortcut}": ${reason}`);
|
|
58
|
+
};
|
|
59
|
+
const hasDuplicateModifiers = (modifiers) => Array.length(Array.dedupe(modifiers)) !== Array.length(modifiers);
|
|
60
|
+
const parsePress = (shortcut) => {
|
|
61
|
+
const tokens = pipe(shortcut, String.split('+'), Array.map(token => pipe(token, String.trim, String.toLowerCase)));
|
|
62
|
+
if (Array.some(tokens, String.isEmpty)) {
|
|
63
|
+
return throwInvalidShortcut(shortcut, 'each modifier and key must be named; use "Plus" for the + key');
|
|
64
|
+
}
|
|
65
|
+
const maybeNonCanonicalModifier = Array.findFirst(tokens, token => Array.contains(NON_CANONICAL_MODIFIERS, token));
|
|
66
|
+
if (Option.isSome(maybeNonCanonicalModifier)) {
|
|
67
|
+
return throwInvalidShortcut(shortcut, `unknown modifier "${maybeNonCanonicalModifier.value}"`);
|
|
68
|
+
}
|
|
69
|
+
const [modifiers, keyToken] = Array.unappend(tokens);
|
|
70
|
+
const key = normalizeKey(keyToken);
|
|
71
|
+
if (Array.contains(SHORTCUT_MODIFIERS, keyToken) ||
|
|
72
|
+
Array.contains(MODIFIER_KEYS, key)) {
|
|
73
|
+
return throwInvalidShortcut(shortcut, 'a non-modifier key is required');
|
|
74
|
+
}
|
|
75
|
+
const maybeUnknownModifier = Array.findFirst(modifiers, modifier => !Array.contains(SHORTCUT_MODIFIERS, modifier));
|
|
76
|
+
if (Option.isSome(maybeUnknownModifier)) {
|
|
77
|
+
return throwInvalidShortcut(shortcut, `unknown modifier "${maybeUnknownModifier.value}"`);
|
|
78
|
+
}
|
|
79
|
+
if (hasDuplicateModifiers(modifiers)) {
|
|
80
|
+
return throwInvalidShortcut(shortcut, 'a modifier is repeated');
|
|
81
|
+
}
|
|
82
|
+
const isModRequired = Array.contains(modifiers, 'mod');
|
|
83
|
+
const isControlRequired = Array.contains(modifiers, 'control');
|
|
84
|
+
const isMetaRequired = Array.contains(modifiers, 'meta');
|
|
85
|
+
if (isModRequired && (isControlRequired || isMetaRequired)) {
|
|
86
|
+
return throwInvalidShortcut(shortcut, 'Mod cannot be combined with Control or Meta');
|
|
87
|
+
}
|
|
88
|
+
const isAltRequired = Array.contains(modifiers, 'alt');
|
|
89
|
+
const isShiftRequired = Array.contains(modifiers, 'shift');
|
|
90
|
+
const pressRequirements = {
|
|
91
|
+
key,
|
|
92
|
+
isAltRequired,
|
|
93
|
+
isControlRequired,
|
|
94
|
+
isMetaRequired,
|
|
95
|
+
isModRequired,
|
|
96
|
+
isShiftRequired,
|
|
97
|
+
};
|
|
98
|
+
return {
|
|
99
|
+
...pressRequirements,
|
|
100
|
+
identifier: pressIdentifier(pressRequirements),
|
|
101
|
+
};
|
|
102
|
+
};
|
|
103
|
+
const compileBinding = (binding) => {
|
|
104
|
+
if (!Predicate.isString(binding.shortcut) &&
|
|
105
|
+
Array.length(binding.shortcut) < 2) {
|
|
106
|
+
throw new Error('A keyboard shortcut sequence requires at least two keys');
|
|
107
|
+
}
|
|
108
|
+
const presses = Predicate.isString(binding.shortcut)
|
|
109
|
+
? Array.of(parsePress(binding.shortcut))
|
|
110
|
+
: Array.map(binding.shortcut, parsePress);
|
|
111
|
+
return {
|
|
112
|
+
presses,
|
|
113
|
+
toMessage: binding.toMessage,
|
|
114
|
+
whileTyping: binding.whileTyping ?? 'Suppress',
|
|
115
|
+
preventDefault: binding.preventDefault ?? true,
|
|
116
|
+
whenRepeated: binding.whenRepeated ?? 'Ignore',
|
|
117
|
+
};
|
|
118
|
+
};
|
|
119
|
+
const bindingLabel = (binding) => pipe(binding.presses, Array.map(press => press.identifier), Array.join(' '));
|
|
120
|
+
const pressesOverlap = (first, second, modKey) => pressIdentifier(first, modKey) === pressIdentifier(second, modKey);
|
|
121
|
+
const isBindingPrefix = (prefix, binding, modKey) => {
|
|
122
|
+
if (Array.length(prefix.presses) > Array.length(binding.presses)) {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
return Array.every(Array.zip(prefix.presses, binding.presses), ([prefixPress, bindingPress]) => pressesOverlap(prefixPress, bindingPress, modKey));
|
|
126
|
+
};
|
|
127
|
+
const sharesFirstPress = (first, second, modKey) => pressesOverlap(Array.headNonEmpty(first.presses), Array.headNonEmpty(second.presses), modKey);
|
|
128
|
+
const validateBindingPair = (binding, otherBinding, modKey) => {
|
|
129
|
+
const bindingIsPrefix = isBindingPrefix(binding, otherBinding, modKey);
|
|
130
|
+
const otherBindingIsPrefix = isBindingPrefix(otherBinding, binding, modKey);
|
|
131
|
+
if (bindingIsPrefix || otherBindingIsPrefix) {
|
|
132
|
+
const relation = Array.length(binding.presses) === Array.length(otherBinding.presses)
|
|
133
|
+
? 'duplicates'
|
|
134
|
+
: 'overlaps as a complete shortcut and a sequence prefix';
|
|
135
|
+
throw new Error(`Keyboard shortcut "${bindingLabel(binding)}" ${relation} "${bindingLabel(otherBinding)}"`);
|
|
136
|
+
}
|
|
137
|
+
if (sharesFirstPress(binding, otherBinding, modKey) &&
|
|
138
|
+
binding.preventDefault !== otherBinding.preventDefault) {
|
|
139
|
+
throw new Error(`Keyboard shortcut sequences beginning with the same key must use the same preventDefault setting: "${bindingLabel(binding)}" and "${bindingLabel(otherBinding)}"`);
|
|
140
|
+
}
|
|
141
|
+
};
|
|
142
|
+
const validateBindings = (bindings, modKey) => {
|
|
143
|
+
Array.forEach(bindings, (binding, index) => {
|
|
144
|
+
Array.forEach(Array.drop(bindings, index + 1), otherBinding => {
|
|
145
|
+
validateBindingPair(binding, otherBinding, modKey);
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
};
|
|
149
|
+
const compileEnabledBindings = (bindings) => pipe(bindings, Array.filter(binding => binding.isEnabled !== false), Array.map(compileBinding));
|
|
150
|
+
const isSequence = (binding) => Array.length(binding.presses) > 1;
|
|
151
|
+
const isModifierEvent = (event) => Array.contains(MODIFIER_KEYS, normalizeKey(event.key));
|
|
152
|
+
const isEditableTarget = (target) => {
|
|
153
|
+
if (!(target instanceof HTMLElement)) {
|
|
154
|
+
return false;
|
|
155
|
+
}
|
|
156
|
+
const tag = String.toLowerCase(target.tagName);
|
|
157
|
+
return (tag === 'input' ||
|
|
158
|
+
tag === 'textarea' ||
|
|
159
|
+
tag === 'select' ||
|
|
160
|
+
target.isContentEditable);
|
|
161
|
+
};
|
|
162
|
+
const isFromEditable = (event) => Array.some(event.composedPath(), isEditableTarget);
|
|
163
|
+
const isAllowedWhileTyping = (binding, isEditable) => !isEditable || binding.whileTyping === 'Allow';
|
|
164
|
+
const resolveModKey = (configuredModKey) => {
|
|
165
|
+
if (configuredModKey !== undefined) {
|
|
166
|
+
return configuredModKey;
|
|
167
|
+
}
|
|
168
|
+
return APPLE_PLATFORM_PATTERN.test(navigator.userAgent) ? 'Meta' : 'Control';
|
|
169
|
+
};
|
|
170
|
+
const pressMatches = (press, event, modKey) => {
|
|
171
|
+
const isControlRequired = press.isControlRequired || (press.isModRequired && modKey === 'Control');
|
|
172
|
+
const isMetaRequired = press.isMetaRequired || (press.isModRequired && modKey === 'Meta');
|
|
173
|
+
return (press.key === normalizeKey(event.key) &&
|
|
174
|
+
press.isAltRequired === event.altKey &&
|
|
175
|
+
isControlRequired === event.ctrlKey &&
|
|
176
|
+
isMetaRequired === event.metaKey &&
|
|
177
|
+
press.isShiftRequired === event.shiftKey);
|
|
178
|
+
};
|
|
179
|
+
const firstPressMatches = (binding, event, modKey) => pressMatches(Array.headNonEmpty(binding.presses), event, modKey);
|
|
180
|
+
const nextPressMatches = (binding, matchedPressCount, event, modKey) => Option.exists(Array.get(binding.presses, matchedPressCount), press => pressMatches(press, event, modKey));
|
|
181
|
+
const resolveTarget = (target) => {
|
|
182
|
+
if (Predicate.isFunction(target)) {
|
|
183
|
+
return target();
|
|
184
|
+
}
|
|
185
|
+
return target ?? document;
|
|
186
|
+
};
|
|
187
|
+
const resolveValidationModKey = (configuredModKey) => {
|
|
188
|
+
if (configuredModKey !== undefined) {
|
|
189
|
+
return Option.some(configuredModKey);
|
|
190
|
+
}
|
|
191
|
+
if (typeof navigator === 'undefined') {
|
|
192
|
+
return Option.none();
|
|
193
|
+
}
|
|
194
|
+
return Option.some(resolveModKey(undefined));
|
|
195
|
+
};
|
|
196
|
+
const compileKeyboardShortcutsConfig = (config) => {
|
|
197
|
+
const bindings = compileEnabledBindings(config.bindings);
|
|
198
|
+
const sequenceTimeout = Duration.toMillis(config.sequenceTimeout ?? DEFAULT_SEQUENCE_TIMEOUT);
|
|
199
|
+
if (!globalThis.Number.isFinite(sequenceTimeout) || sequenceTimeout <= 0) {
|
|
200
|
+
throw new Error('sequenceTimeout must be a finite duration above zero');
|
|
201
|
+
}
|
|
202
|
+
const maybeValidationModKey = resolveValidationModKey(config.modKey);
|
|
203
|
+
if (Option.isSome(maybeValidationModKey)) {
|
|
204
|
+
validateBindings(bindings, maybeValidationModKey.value);
|
|
205
|
+
}
|
|
206
|
+
return {
|
|
207
|
+
bindings,
|
|
208
|
+
modKey: config.modKey,
|
|
209
|
+
sequenceTimeout,
|
|
210
|
+
target: config.target,
|
|
211
|
+
};
|
|
212
|
+
};
|
|
213
|
+
const makeSequenceController = (sequenceTimeout) => {
|
|
214
|
+
const state = {
|
|
215
|
+
maybeSequence: Option.none(),
|
|
216
|
+
};
|
|
217
|
+
const clearSequence = () => {
|
|
218
|
+
if (Option.isSome(state.maybeSequence)) {
|
|
219
|
+
clearTimeout(state.maybeSequence.value.timeout);
|
|
220
|
+
}
|
|
221
|
+
state.maybeSequence = Option.none();
|
|
222
|
+
};
|
|
223
|
+
const setSequence = (sequence) => {
|
|
224
|
+
clearSequence();
|
|
225
|
+
state.maybeSequence = Option.some({
|
|
226
|
+
...sequence,
|
|
227
|
+
timeout: setTimeout(clearSequence, sequenceTimeout),
|
|
228
|
+
});
|
|
229
|
+
};
|
|
230
|
+
return {
|
|
231
|
+
clearSequence,
|
|
232
|
+
readSequence: () => state.maybeSequence,
|
|
233
|
+
setSequence,
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
const emitBindingMessage = (context, binding, event) => {
|
|
237
|
+
context.sequenceController.clearSequence();
|
|
238
|
+
if (binding.preventDefault) {
|
|
239
|
+
event.preventDefault();
|
|
240
|
+
}
|
|
241
|
+
context.emitMessage(binding.toMessage(event));
|
|
242
|
+
};
|
|
243
|
+
const isMatchingOnePressBinding = (event, modKey, isEditable) => (binding) => !isSequence(binding) &&
|
|
244
|
+
isAllowedWhileTyping(binding, isEditable) &&
|
|
245
|
+
(!event.repeat || binding.whenRepeated === 'Allow') &&
|
|
246
|
+
firstPressMatches(binding, event, modKey);
|
|
247
|
+
const startFreshSequence = (context, event) => {
|
|
248
|
+
const isEditable = isFromEditable(event);
|
|
249
|
+
const maybeKeyBinding = Array.findFirst(context.bindings, isMatchingOnePressBinding(event, context.modKey, isEditable));
|
|
250
|
+
if (Option.isSome(maybeKeyBinding)) {
|
|
251
|
+
emitBindingMessage(context, maybeKeyBinding.value, event);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
if (event.repeat) {
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const sequenceBindings = Array.filter(context.bindings, binding => isSequence(binding) &&
|
|
258
|
+
isAllowedWhileTyping(binding, isEditable) &&
|
|
259
|
+
firstPressMatches(binding, event, context.modKey));
|
|
260
|
+
if (Array.isArrayNonEmpty(sequenceBindings)) {
|
|
261
|
+
const firstBinding = Array.headNonEmpty(sequenceBindings);
|
|
262
|
+
context.sequenceController.setSequence({
|
|
263
|
+
bindings: sequenceBindings,
|
|
264
|
+
matchedPressCount: 1,
|
|
265
|
+
});
|
|
266
|
+
if (firstBinding.preventDefault) {
|
|
267
|
+
event.preventDefault();
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
};
|
|
271
|
+
const continueSequence = (context, sequence, event) => {
|
|
272
|
+
const isEditable = isFromEditable(event);
|
|
273
|
+
const matchingBindings = Array.filter(sequence.bindings, binding => isAllowedWhileTyping(binding, isEditable) &&
|
|
274
|
+
nextPressMatches(binding, sequence.matchedPressCount, event, context.modKey));
|
|
275
|
+
if (!Array.isReadonlyArrayNonEmpty(matchingBindings)) {
|
|
276
|
+
context.sequenceController.clearSequence();
|
|
277
|
+
startFreshSequence(context, event);
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
const firstBinding = Array.headNonEmpty(matchingBindings);
|
|
281
|
+
const nextMatchedPressCount = Number.increment(sequence.matchedPressCount);
|
|
282
|
+
const maybeCompletedBinding = Array.findFirst(matchingBindings, binding => Array.length(binding.presses) === nextMatchedPressCount);
|
|
283
|
+
if (Option.isSome(maybeCompletedBinding)) {
|
|
284
|
+
emitBindingMessage(context, maybeCompletedBinding.value, event);
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
context.sequenceController.setSequence({
|
|
288
|
+
bindings: matchingBindings,
|
|
289
|
+
matchedPressCount: nextMatchedPressCount,
|
|
290
|
+
});
|
|
291
|
+
if (firstBinding.preventDefault) {
|
|
292
|
+
event.preventDefault();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
const handleKeyboardShortcutEvent = (context, event) => {
|
|
296
|
+
if (!(event instanceof KeyboardEvent)) {
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (event.defaultPrevented || event.isComposing) {
|
|
300
|
+
context.sequenceController.clearSequence();
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
if (isModifierEvent(event)) {
|
|
304
|
+
return;
|
|
305
|
+
}
|
|
306
|
+
const maybeSequence = context.sequenceController.readSequence();
|
|
307
|
+
if (event.repeat && Option.isSome(maybeSequence)) {
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
Option.match(maybeSequence, {
|
|
311
|
+
onNone: () => startFreshSequence(context, event),
|
|
312
|
+
onSome: sequence => continueSequence(context, sequence, event),
|
|
313
|
+
});
|
|
314
|
+
};
|
|
315
|
+
const makeKeyboardShortcutHandler = (config) => {
|
|
316
|
+
const sequenceController = makeSequenceController(config.sequenceTimeout);
|
|
317
|
+
const context = {
|
|
318
|
+
bindings: config.bindings,
|
|
319
|
+
emitMessage: config.emitMessage,
|
|
320
|
+
modKey: config.modKey,
|
|
321
|
+
sequenceController,
|
|
322
|
+
};
|
|
323
|
+
return {
|
|
324
|
+
clearSequence: sequenceController.clearSequence,
|
|
325
|
+
handleEvent: event => handleKeyboardShortcutEvent(context, event),
|
|
326
|
+
};
|
|
327
|
+
};
|
|
328
|
+
const resolveOwnerDocument = (target) => {
|
|
329
|
+
if (target instanceof Document) {
|
|
330
|
+
return target;
|
|
331
|
+
}
|
|
332
|
+
if (target instanceof Node && target.ownerDocument !== null) {
|
|
333
|
+
return target.ownerDocument;
|
|
334
|
+
}
|
|
335
|
+
return document;
|
|
336
|
+
};
|
|
337
|
+
const acquireKeyboardShortcutListener = (config, emitMessage) => {
|
|
338
|
+
const target = resolveTarget(config.target);
|
|
339
|
+
const modKey = resolveModKey(config.modKey);
|
|
340
|
+
validateBindings(config.bindings, modKey);
|
|
341
|
+
const handler = makeKeyboardShortcutHandler({
|
|
342
|
+
bindings: config.bindings,
|
|
343
|
+
emitMessage,
|
|
344
|
+
modKey,
|
|
345
|
+
sequenceTimeout: config.sequenceTimeout,
|
|
346
|
+
});
|
|
347
|
+
const ownerDocument = resolveOwnerDocument(target);
|
|
348
|
+
const ownerWindow = ownerDocument.defaultView ?? window;
|
|
349
|
+
target.addEventListener('keydown', handler.handleEvent);
|
|
350
|
+
ownerWindow.addEventListener('blur', handler.clearSequence);
|
|
351
|
+
ownerDocument.addEventListener('visibilitychange', handler.clearSequence);
|
|
352
|
+
return {
|
|
353
|
+
...handler,
|
|
354
|
+
ownerDocument,
|
|
355
|
+
ownerWindow,
|
|
356
|
+
target,
|
|
357
|
+
};
|
|
358
|
+
};
|
|
359
|
+
const releaseKeyboardShortcutListener = (listener) => {
|
|
360
|
+
listener.target.removeEventListener('keydown', listener.handleEvent);
|
|
361
|
+
listener.ownerWindow.removeEventListener('blur', listener.clearSequence);
|
|
362
|
+
listener.ownerDocument.removeEventListener('visibilitychange', listener.clearSequence);
|
|
363
|
+
listener.clearSequence();
|
|
364
|
+
};
|
|
365
|
+
const keyboardShortcutStream = (config) => Stream.callback(queue => {
|
|
366
|
+
const emitMessage = (message) => {
|
|
367
|
+
Queue.offerUnsafe(queue, message);
|
|
368
|
+
};
|
|
369
|
+
return Effect.acquireRelease(Effect.sync(() => acquireKeyboardShortcutListener(config, emitMessage)), listener => Effect.sync(() => releaseKeyboardShortcutListener(listener)));
|
|
370
|
+
});
|
|
371
|
+
/**
|
|
372
|
+
* Build a Stream that turns declarative keyboard shortcuts into Messages.
|
|
373
|
+
*
|
|
374
|
+
* A string shortcut describes one key press. Modifiers are joined with `+`:
|
|
375
|
+
* `'Mod+K'`, `'Control+Shift+P'`, or `'Alt+ArrowDown'`. The supported modifiers
|
|
376
|
+
* are `Mod`, `Control`, `Meta`, `Alt`, and `Shift`. `Mod` resolves to Meta on
|
|
377
|
+
* Apple platforms and Control elsewhere; `modKey` can override that choice.
|
|
378
|
+
* Matching uses `KeyboardEvent.key`, case-insensitively, after the active
|
|
379
|
+
* keyboard layout has been applied. Use `Space` and `Plus` for those keys.
|
|
380
|
+
*
|
|
381
|
+
* An array describes an ordered sequence of two or more presses. Every press
|
|
382
|
+
* uses the same grammar, so `['G', 'Shift+G']` is valid. Sequences reset after
|
|
383
|
+
* one second by default; `sequenceTimeout` accepts any Effect Duration input.
|
|
384
|
+
* Modifier-only events and repeated keydowns do not advance a sequence.
|
|
385
|
+
*
|
|
386
|
+
* Shortcuts are suppressed by default when the event's composed path contains
|
|
387
|
+
* an `input`, `textarea`, `select`, or contenteditable element. Set
|
|
388
|
+
* `whileTyping` to `'Allow'` for a binding that must work there. Events emitted
|
|
389
|
+
* during IME composition are always ignored. Repeated keydowns are ignored for
|
|
390
|
+
* one-press shortcuts unless `whenRepeated` is `'Allow'`. An event another
|
|
391
|
+
* handler already canceled is ignored and clears any sequence in progress.
|
|
392
|
+
*
|
|
393
|
+
* Matched key presses call `preventDefault()` before dispatching. For a
|
|
394
|
+
* sequence, that policy applies to every matched press. Set `preventDefault`
|
|
395
|
+
* to `false` to opt out. Sequences sharing a prefix must use the same policy.
|
|
396
|
+
* Duplicate bindings and a complete shortcut that is also a sequence prefix
|
|
397
|
+
* are rejected when the Stream is created.
|
|
398
|
+
*
|
|
399
|
+
* This helper returns a Stream, not a complete Subscription entry. Use
|
|
400
|
+
* `Subscription.persistent` for a fixed table. When availability depends on
|
|
401
|
+
* the Model that owns the entry, build it inside `dependenciesToStream` and
|
|
402
|
+
* derive each binding's `isEnabled` from the dependency record. A dependency
|
|
403
|
+
* change opens a new Stream scope and resets any sequence in progress. If a
|
|
404
|
+
* parent owns a condition for a lifted child, declare the table at that parent
|
|
405
|
+
* or put shortcuts with different parent-owned lifetimes in separate child
|
|
406
|
+
* entries so `Subscription.lift` can gate them individually. If the meaning
|
|
407
|
+
* of a key depends on the Model, dispatch a factual key Message and decide
|
|
408
|
+
* what it means in update instead of reading the Model from `toMessage`.
|
|
409
|
+
*
|
|
410
|
+
* @example
|
|
411
|
+
* ```typescript
|
|
412
|
+
* const subscriptions = Subscription.make<Model, Message>()(entry => ({
|
|
413
|
+
* shortcuts: entry(
|
|
414
|
+
* { isPaletteOpen: Schema.Boolean },
|
|
415
|
+
* {
|
|
416
|
+
* modelToDependencies: model => ({
|
|
417
|
+
* isPaletteOpen: model.paletteState._tag === 'Open',
|
|
418
|
+
* }),
|
|
419
|
+
* dependenciesToStream: ({ isPaletteOpen }) =>
|
|
420
|
+
* Subscription.keyboardShortcuts<Message>({
|
|
421
|
+
* bindings: [
|
|
422
|
+
* {
|
|
423
|
+
* shortcut: 'Escape',
|
|
424
|
+
* isEnabled: isPaletteOpen,
|
|
425
|
+
* whileTyping: 'Allow',
|
|
426
|
+
* toMessage: () => Message.PressedEscape(),
|
|
427
|
+
* },
|
|
428
|
+
* {
|
|
429
|
+
* shortcut: 'Mod+K',
|
|
430
|
+
* whileTyping: 'Allow',
|
|
431
|
+
* toMessage: () => Message.PressedSearchShortcut(),
|
|
432
|
+
* },
|
|
433
|
+
* {
|
|
434
|
+
* shortcut: ['G', 'L'],
|
|
435
|
+
* toMessage: () => Message.PressedListShortcut(),
|
|
436
|
+
* },
|
|
437
|
+
* ],
|
|
438
|
+
* }),
|
|
439
|
+
* },
|
|
440
|
+
* ),
|
|
441
|
+
* }))
|
|
442
|
+
* ```
|
|
443
|
+
*/
|
|
444
|
+
export const keyboardShortcuts = (config) => keyboardShortcutStream(compileKeyboardShortcutsConfig(config));
|
|
@@ -3,5 +3,7 @@ export type { EntryWithoutKeepAlive, GatedDependencies, Subscription, Subscripti
|
|
|
3
3
|
export { animationFrame } from './animationFrame.js';
|
|
4
4
|
export type { AnimationFrameConfig } from './animationFrame.js';
|
|
5
5
|
export { fromEvent, fromEventFilterMap, fromEventFilterMapPreventDefault, } from './fromEvent.js';
|
|
6
|
-
export type { FromEventConfig, FromEventFilterMapConfig, FromEventFilterMapPreventDefaultConfig, } from './fromEvent.js';
|
|
6
|
+
export type { FromEventConfig, FromEventFilterMapConfig, FromEventFilterMapPreventDefaultConfig, TypedEventTarget, } from './fromEvent.js';
|
|
7
|
+
export { keyboardShortcuts } from './keyboardShortcuts.js';
|
|
8
|
+
export type { KeyboardShortcut, KeyboardShortcutBinding, KeyboardShortcutsConfig, WhileTyping, } from './keyboardShortcuts.js';
|
|
7
9
|
//# sourceMappingURL=public.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/subscription/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE,YAAY,EACV,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,EACZ,aAAa,GACd,MAAM,mBAAmB,CAAA;AAE1B,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAE/D,OAAO,EACL,SAAS,EACT,kBAAkB,EAClB,gCAAgC,GACjC,MAAM,gBAAgB,CAAA;AAEvB,YAAY,EACV,eAAe,EACf,wBAAwB,EACxB,sCAAsC,
|
|
1
|
+
{"version":3,"file":"public.d.ts","sourceRoot":"","sources":["../../src/subscription/public.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,IAAI,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAA;AAErE,YAAY,EACV,qBAAqB,EACrB,iBAAiB,EACjB,YAAY,EACZ,aAAa,GACd,MAAM,mBAAmB,CAAA;AAE1B,OAAO,EAAE,cAAc,EAAE,MAAM,qBAAqB,CAAA;AAEpD,YAAY,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAA;AAE/D,OAAO,EACL,SAAS,EACT,kBAAkB,EAClB,gCAAgC,GACjC,MAAM,gBAAgB,CAAA;AAEvB,YAAY,EACV,eAAe,EACf,wBAAwB,EACxB,sCAAsC,EACtC,gBAAgB,GACjB,MAAM,gBAAgB,CAAA;AAEvB,OAAO,EAAE,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAE1D,YAAY,EACV,gBAAgB,EAChB,uBAAuB,EACvB,uBAAuB,EACvB,WAAW,GACZ,MAAM,wBAAwB,CAAA"}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { aggregate, lift, make, persistent } from './subscription.js';
|
|
2
2
|
export { animationFrame } from './animationFrame.js';
|
|
3
3
|
export { fromEvent, fromEventFilterMap, fromEventFilterMapPreventDefault, } from './fromEvent.js';
|
|
4
|
+
export { keyboardShortcuts } from './keyboardShortcuts.js';
|
|
@@ -114,12 +114,72 @@ export type EntryBuilder<Model, Message, Services> = <const Fields extends Schem
|
|
|
114
114
|
* ```
|
|
115
115
|
*/
|
|
116
116
|
export declare const make: <Model, Message, Services = never>() => <Entries extends Readonly<Record<string, Entry<Model, Message, any, Services>>>>(build: (entry: EntryBuilder<Model, Message, Services>) => Entries) => { readonly [K in keyof Entries]: Entries[K] & SubscriptionBrand; };
|
|
117
|
+
type AnySubscriptions = Readonly<Record<string, Subscription<any, any, any, any>>>;
|
|
118
|
+
type AnySubscriptionsList = readonly [
|
|
119
|
+
AnySubscriptions,
|
|
120
|
+
...ReadonlyArray<AnySubscriptions>
|
|
121
|
+
];
|
|
122
|
+
type EntriesOfRecord<SubscriptionsRecord> = SubscriptionsRecord extends unknown ? SubscriptionsRecord[keyof SubscriptionsRecord] : never;
|
|
123
|
+
type EntriesOf<Records extends AnySubscriptionsList> = EntriesOfRecord<Records[number]>;
|
|
124
|
+
type ModelOfEntry<AnyEntry> = [AnyEntry] extends [
|
|
125
|
+
Subscription<infer Model, any, any, any>
|
|
126
|
+
] ? unknown extends Model ? never : Model : never;
|
|
127
|
+
type MessageOfEntry<AnyEntry> = AnyEntry extends Subscription<any, infer Message, any, any> ? Message : never;
|
|
128
|
+
type ServicesOfEntry<AnyEntry> = AnyEntry extends Subscription<any, any, any, infer Services> ? Services : never;
|
|
129
|
+
type MessageOf<Records extends AnySubscriptionsList> = MessageOfEntry<EntriesOf<Records>>;
|
|
130
|
+
type ServicesOf<Records extends AnySubscriptionsList> = ServicesOfEntry<EntriesOf<Records>>;
|
|
131
|
+
type ReferenceModel<Records extends ReadonlyArray<AnySubscriptions>> = Records extends readonly [
|
|
132
|
+
infer Head extends AnySubscriptions,
|
|
133
|
+
...infer Rest extends ReadonlyArray<AnySubscriptions>
|
|
134
|
+
] ? [ModelOfEntry<EntriesOfRecord<Head>>] extends [never] ? ReferenceModel<Rest> : ModelOfEntry<EntriesOfRecord<Head>> : never;
|
|
135
|
+
type CompatibleSubscriptions<Records extends AnySubscriptionsList> = {
|
|
136
|
+
readonly [Index in keyof Records]: Records[Index] & Subscriptions<ReferenceModel<Records>, MessageOf<Records>, ServicesOf<Records>>;
|
|
137
|
+
};
|
|
138
|
+
type RuntimeKey<Key> = Key extends string ? Key : Key extends number ? `${Key}` : never;
|
|
139
|
+
type RuntimeEntries<RecordType> = {
|
|
140
|
+
readonly [Key in keyof RecordType as RuntimeKey<Key>]: RecordType[Key];
|
|
141
|
+
};
|
|
142
|
+
type MergeSubscriptions<Records extends ReadonlyArray<unknown>> = Records extends readonly [infer Head, ...infer Rest] ? RuntimeEntries<Head> & (Rest extends ReadonlyArray<unknown> ? MergeSubscriptions<Rest> : {}) : {};
|
|
117
143
|
/**
|
|
118
144
|
* Combines multiple Subscriptions records into one. Throws on duplicate
|
|
119
145
|
* keys so a misconfigured aggregate fails loudly at startup rather than
|
|
120
146
|
* silently overriding.
|
|
147
|
+
*
|
|
148
|
+
* Pass the records directly and the Model, Message, and Services are read
|
|
149
|
+
* off them. The Model of the first record with a Model dependency is the one
|
|
150
|
+
* every later record is checked against, so a record from another Model
|
|
151
|
+
* universe fails at its own argument position. Message and Services widen to
|
|
152
|
+
* the union across all records, which is what lets a record that needs an
|
|
153
|
+
* Effect service sit beside records that need none.
|
|
154
|
+
*
|
|
155
|
+
* The result keeps each record's keys and each entry's exact dependency type,
|
|
156
|
+
* schema, and `keepAliveEquivalence` variant, so a lifted entry's
|
|
157
|
+
* {@link GatedDependencies} survives aggregation.
|
|
158
|
+
*
|
|
159
|
+
* The curried form remains available for a record that has to be typed before
|
|
160
|
+
* its entries exist, such as a value annotated as
|
|
161
|
+
* `Subscriptions<Model, Message>` at a module boundary. It erases keys and
|
|
162
|
+
* per-entry dependency types, so reach for it only when the explicit contract
|
|
163
|
+
* is the point.
|
|
164
|
+
*
|
|
165
|
+
* @example
|
|
166
|
+
* ```ts
|
|
167
|
+
* const subscriptions = Subscription.aggregate(
|
|
168
|
+
* homeSubscriptions,
|
|
169
|
+
* roomSubscriptions,
|
|
170
|
+
* )
|
|
171
|
+
* ```
|
|
172
|
+
*
|
|
173
|
+
* @example Curried, when the record is typed before its entries exist:
|
|
174
|
+
* ```ts
|
|
175
|
+
* export const subscriptions: Subscription.Subscriptions<Model, Message> =
|
|
176
|
+
* Subscription.aggregate<Model, Message>()(...records)
|
|
177
|
+
* ```
|
|
121
178
|
*/
|
|
122
|
-
export declare const aggregate:
|
|
179
|
+
export declare const aggregate: {
|
|
180
|
+
<Model, Message, Services = never>(): (...records: ReadonlyArray<Subscriptions<Model, Message, Services>>) => Subscriptions<Model, Message, Services>;
|
|
181
|
+
<Records extends AnySubscriptionsList>(...records: CompatibleSubscriptions<Records>): MergeSubscriptions<Records>;
|
|
182
|
+
};
|
|
123
183
|
/**
|
|
124
184
|
* Wraps a Stream as a Subscription entry whose lifecycle is independent of
|
|
125
185
|
* the Model. The Stream runs for the lifetime of the Subscriptions record;
|