@enigmax/primitives 0.21.0 → 0.23.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/chunk-3BQVOOAM.js +388 -0
- package/dist/{chunk-QQFNAKMY.js → chunk-45UHLZYT.js} +1 -1
- package/dist/chunk-4VUHQFAT.js +130 -0
- package/dist/{chunk-D5A2ZMAG.js → chunk-4ZPMP47J.js} +6 -1
- package/dist/chunk-DSBYVA7V.js +713 -0
- package/dist/{chunk-FWVWX67R.js → chunk-FGBIZDV2.js} +3 -3
- package/dist/{chunk-R4ZAEE7V.js → chunk-KEVZ5XQV.js} +1 -1
- package/dist/chunk-N6PDHMAX.js +213 -0
- package/dist/chunk-NONGREXC.js +248 -0
- package/dist/chunk-VKL3DEIQ.js +804 -0
- package/dist/chunk-WPTBURIC.js +1627 -0
- package/dist/chunk-WSQC3PCC.js +466 -0
- package/dist/context-menu-D3FtTn7v.d.ts +174 -0
- package/dist/{index-dTdAbOWl.d.ts → index-DQNnohoo.d.ts} +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +8 -4
- package/dist/keys-D2zJs1uB.d.ts +100 -0
- package/dist/next/index.d.ts +12 -5
- package/dist/next/index.js +21 -14
- package/dist/react/button.d.ts +2 -2
- package/dist/react/context-menu.d.ts +202 -0
- package/dist/react/context-menu.js +6 -0
- package/dist/react/index.d.ts +11 -4
- package/dist/react/index.js +20 -13
- package/dist/react/input.d.ts +2 -2
- package/dist/react/input.js +1 -1
- package/dist/react/palette.d.ts +1 -1
- package/dist/react/palette.js +3 -3
- package/dist/react/search.d.ts +1 -1
- package/dist/react/search.js +2 -2
- package/dist/react/select.d.ts +217 -0
- package/dist/react/select.js +7 -0
- package/dist/react/selection.d.ts +104 -0
- package/dist/react/selection.js +4 -0
- package/dist/react/slot.d.ts +2 -2
- package/dist/react/toast.d.ts +165 -32
- package/dist/react/toast.js +1 -1
- package/dist/react-router/index.d.ts +12 -5
- package/dist/react-router/index.js +21 -14
- package/dist/search/index.d.ts +2 -2
- package/dist/search/index.js +1 -1
- package/dist/{search-DXxY8SEH.d.ts → search-DYgqRp37.d.ts} +20 -3
- package/dist/{search-UQEXAPQB.js → search-PBZORZ7P.js} +1 -1
- package/dist/select-ClSy-J1f.d.ts +100 -0
- package/dist/selection-B_pmzHpy.d.ts +150 -0
- package/package.json +21 -2
- package/recipes/context-menu/styles.css +177 -0
- package/recipes/input/styles.css +9 -0
- package/recipes/palette/styles.css +3 -0
- package/recipes/search/tailwind.tsx +3 -2
- package/recipes/select/styles.css +230 -0
- package/recipes/toast/styles.css +688 -192
- package/registry.json +434 -29
- package/src/core/context-menu.ts +694 -0
- package/src/core/keys.ts +264 -0
- package/src/core/search.ts +25 -1
- package/src/core/select.ts +404 -0
- package/src/core/selection.ts +648 -0
- package/src/index.ts +60 -1
- package/src/react/context-menu/context.ts +57 -0
- package/src/react/context-menu/index.tsx +94 -0
- package/src/react/context-menu/root.tsx +846 -0
- package/src/react/context-menu/styles.ts +186 -0
- package/src/react/index.ts +70 -2
- package/src/react/palette/root.tsx +2 -2
- package/src/react/select/context.ts +56 -0
- package/src/react/select/index.tsx +96 -0
- package/src/react/select/root.tsx +839 -0
- package/src/react/select/styles.ts +238 -0
- package/src/react/selection/index.tsx +115 -0
- package/src/react/selection/use-selection.ts +260 -0
- package/src/react/toast/NOTICE +20 -0
- package/src/react/toast/assets.tsx +85 -0
- package/src/react/toast/cn.ts +10 -0
- package/src/react/toast/hooks.ts +13 -0
- package/src/react/toast/index.tsx +736 -0
- package/src/react/toast/state.ts +207 -0
- package/src/react/toast/styles.ts +738 -0
- package/src/react/toast/types.ts +193 -0
- package/src/react/toaster.tsx +76 -341
- package/dist/chunk-JCCL7XKC.js +0 -465
- package/src/react/toast-styles.ts +0 -247
- /package/dist/{chunk-U3V4EHOB.js → chunk-3HDEZ2E7.js} +0 -0
package/src/core/keys.ts
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Keyboard shortcuts as data: parsed from a string, matched against an event, and written
|
|
3
|
+
* back out the way the platform writes them.
|
|
4
|
+
*
|
|
5
|
+
* Two components need the same three things and would otherwise each get their own half of
|
|
6
|
+
* them - the context menu prints a shortcut beside an action, and the selection list matches
|
|
7
|
+
* one against a key press. A menu whose label says `Ctrl+A` while the list listens for
|
|
8
|
+
* `Meta+A` is the defect that shape produces, so both read this file.
|
|
9
|
+
*
|
|
10
|
+
* `Mod` is the whole point of the spec being a string. It means Command on an Apple keyboard
|
|
11
|
+
* and Control everywhere else, which is what every one of these shortcuts actually means -
|
|
12
|
+
* hardcoding either one is wrong on half the machines.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** One shortcut, normalized. `mod` and `ctrl`/`meta` are exclusive: `Mod` sets only `mod`. */
|
|
16
|
+
export interface Shortcut {
|
|
17
|
+
/** The `KeyboardEvent.key` to match, lowercased for letters (`a`, `f2`, `delete`, ` `). */
|
|
18
|
+
key: string;
|
|
19
|
+
/** Command on an Apple platform, Control elsewhere. */
|
|
20
|
+
mod?: boolean;
|
|
21
|
+
ctrl?: boolean;
|
|
22
|
+
meta?: boolean;
|
|
23
|
+
shift?: boolean;
|
|
24
|
+
alt?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The parts of a key press a shortcut reads. A real KeyboardEvent satisfies it. */
|
|
28
|
+
export interface ShortcutEvent {
|
|
29
|
+
key: string;
|
|
30
|
+
ctrlKey?: boolean;
|
|
31
|
+
metaKey?: boolean;
|
|
32
|
+
shiftKey?: boolean;
|
|
33
|
+
altKey?: boolean;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A shortcut as written: `"Mod+A"`, `"Shift+F10"`, `"Delete"`, `"Ctrl+Shift+N"`. A list means
|
|
38
|
+
* several presses do the same thing, and `false` means the command has no binding at all.
|
|
39
|
+
*/
|
|
40
|
+
export type ShortcutSpec = string | Shortcut | readonly (string | Shortcut)[] | false;
|
|
41
|
+
|
|
42
|
+
const MODIFIERS = new Set(["mod", "ctrl", "control", "meta", "cmd", "command", "super", "win", "shift", "alt", "option", "opt"]);
|
|
43
|
+
|
|
44
|
+
/** Names that are not one character but are one key. Written the way `KeyboardEvent.key` spells them. */
|
|
45
|
+
const NAMED: Record<string, string> = {
|
|
46
|
+
esc: "escape",
|
|
47
|
+
escape: "escape",
|
|
48
|
+
del: "delete",
|
|
49
|
+
delete: "delete",
|
|
50
|
+
back: "backspace",
|
|
51
|
+
backspace: "backspace",
|
|
52
|
+
enter: "enter",
|
|
53
|
+
return: "enter",
|
|
54
|
+
space: " ",
|
|
55
|
+
spacebar: " ",
|
|
56
|
+
tab: "tab",
|
|
57
|
+
up: "arrowup",
|
|
58
|
+
down: "arrowdown",
|
|
59
|
+
left: "arrowleft",
|
|
60
|
+
right: "arrowright",
|
|
61
|
+
arrowup: "arrowup",
|
|
62
|
+
arrowdown: "arrowdown",
|
|
63
|
+
arrowleft: "arrowleft",
|
|
64
|
+
arrowright: "arrowright",
|
|
65
|
+
home: "home",
|
|
66
|
+
end: "end",
|
|
67
|
+
pageup: "pageup",
|
|
68
|
+
pagedown: "pagedown",
|
|
69
|
+
plus: "+"
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Whether this is an Apple keyboard, and so whether `Mod` is Command.
|
|
74
|
+
*
|
|
75
|
+
* `navigator.platform` is deprecated and still the only reliable answer where it exists, so
|
|
76
|
+
* it is tried first and the user agent is the fallback. A server has neither and gets `false`,
|
|
77
|
+
* which is the right guess: a label rendered on the server is corrected on hydration, and a
|
|
78
|
+
* key press cannot happen there at all.
|
|
79
|
+
*/
|
|
80
|
+
export function isApplePlatform(): boolean {
|
|
81
|
+
if (typeof navigator === "undefined") return false;
|
|
82
|
+
const source = navigator.platform || navigator.userAgent || "";
|
|
83
|
+
return /mac|iphone|ipad|ipod/i.test(source);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** `"Mod+Shift+A"` -> the shortcut it stands for. Unknown words are treated as the key. */
|
|
87
|
+
export function parseShortcut(spec: string | Shortcut): Shortcut {
|
|
88
|
+
if (typeof spec !== "string") return { ...spec, key: normalizeKey(spec.key) };
|
|
89
|
+
|
|
90
|
+
const shortcut: Shortcut = { key: "" };
|
|
91
|
+
// Split on + and - so `Ctrl-A` reads the same as `Ctrl+A`, but never on a LONE separator:
|
|
92
|
+
// `Ctrl++` and `Ctrl+-` are real shortcuts whose key is the separator itself.
|
|
93
|
+
const parts = spec.split(/[+-](?!$)/).map((part) => part.trim()).filter(Boolean);
|
|
94
|
+
if (parts.length === 0) return { key: normalizeKey(spec.trim()) };
|
|
95
|
+
|
|
96
|
+
parts.forEach((part, index) => {
|
|
97
|
+
const word = part.toLowerCase();
|
|
98
|
+
const last = index === parts.length - 1;
|
|
99
|
+
// The last word is the KEY even when it names a modifier: `Shift` alone is a
|
|
100
|
+
// shortcut, and so is the `Alt` in `Ctrl+Alt`.
|
|
101
|
+
if (!last && MODIFIERS.has(word)) {
|
|
102
|
+
applyModifier(shortcut, word);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (last && MODIFIERS.has(word) && parts.length > 1) {
|
|
106
|
+
applyModifier(shortcut, word);
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
shortcut.key = normalizeKey(part);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
// `Ctrl+Alt` with no key left: the trailing modifier IS the key.
|
|
113
|
+
if (!shortcut.key) shortcut.key = normalizeKey(parts[parts.length - 1]);
|
|
114
|
+
return shortcut;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function applyModifier(shortcut: Shortcut, word: string): void {
|
|
118
|
+
if (word === "mod") shortcut.mod = true;
|
|
119
|
+
else if (word === "ctrl" || word === "control") shortcut.ctrl = true;
|
|
120
|
+
else if (word === "meta" || word === "cmd" || word === "command" || word === "super" || word === "win") shortcut.meta = true;
|
|
121
|
+
else if (word === "shift") shortcut.shift = true;
|
|
122
|
+
else shortcut.alt = true;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* `KeyboardEvent.key` as this file compares it: lowercase, with the aliases resolved.
|
|
127
|
+
*
|
|
128
|
+
* NOT trimmed, and that is the whole comment: the space bar reports its key as `" "`, so
|
|
129
|
+
* trimming here turns Ctrl+Space into Ctrl+nothing and the binding silently never matches.
|
|
130
|
+
* The spec's own words are trimmed where they are split instead.
|
|
131
|
+
*/
|
|
132
|
+
function normalizeKey(key: string): string {
|
|
133
|
+
const lower = key.toLowerCase();
|
|
134
|
+
return NAMED[lower] ?? lower;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** A spec as a list, so one binding and several read the same downstream. */
|
|
138
|
+
export function shortcutList(spec: ShortcutSpec): Shortcut[] {
|
|
139
|
+
if (spec === false || spec == null) return [];
|
|
140
|
+
const entries = Array.isArray(spec) ? spec : [spec as string | Shortcut];
|
|
141
|
+
return entries.map(parseShortcut);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Whether a key press is this shortcut.
|
|
146
|
+
*
|
|
147
|
+
* Every modifier is checked, including the ones the shortcut does NOT ask for: `Delete` must
|
|
148
|
+
* not fire on `Ctrl+Delete`, which means something else in every file manager there is. Shift
|
|
149
|
+
* is the one exception the caller can waive, because a shifted letter arrives as a different
|
|
150
|
+
* `key` on some layouts.
|
|
151
|
+
*/
|
|
152
|
+
export function matchesShortcut(event: ShortcutEvent, spec: ShortcutSpec, apple = isApplePlatform()): boolean {
|
|
153
|
+
return shortcutList(spec).some((shortcut) => matchesOne(event, shortcut, apple));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function matchesOne(event: ShortcutEvent, shortcut: Shortcut, apple: boolean): boolean {
|
|
157
|
+
const key = normalizeKey(event.key);
|
|
158
|
+
// `Mod` resolves to exactly one physical modifier, so Ctrl+A on a Mac is NOT Cmd+A: it
|
|
159
|
+
// is the terminal's start-of-line, and a list that stole it would be the thing at fault.
|
|
160
|
+
const wantCtrl = Boolean(shortcut.ctrl || (shortcut.mod && !apple));
|
|
161
|
+
const wantMeta = Boolean(shortcut.meta || (shortcut.mod && apple));
|
|
162
|
+
return key === shortcut.key
|
|
163
|
+
&& Boolean(event.ctrlKey) === wantCtrl
|
|
164
|
+
&& Boolean(event.metaKey) === wantMeta
|
|
165
|
+
&& Boolean(event.shiftKey) === Boolean(shortcut.shift)
|
|
166
|
+
&& Boolean(event.altKey) === Boolean(shortcut.alt);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The shortcut written the way this platform writes it, as tokens.
|
|
171
|
+
*
|
|
172
|
+
* Tokens rather than a string because that is what a menu renders: one `<kbd>` per key, so
|
|
173
|
+
* `Ctrl` and `A` can be spaced and styled apart. `shortcutText` joins them for anything that
|
|
174
|
+
* only has room for a string.
|
|
175
|
+
*/
|
|
176
|
+
export function shortcutTokens(spec: string | Shortcut, apple = isApplePlatform()): string[] {
|
|
177
|
+
const shortcut = parseShortcut(spec);
|
|
178
|
+
const tokens: string[] = [];
|
|
179
|
+
// Apple's own order, which is also the order Windows uses for the modifiers it has:
|
|
180
|
+
// Control, Option/Alt, Shift, Command.
|
|
181
|
+
if (shortcut.ctrl || (shortcut.mod && !apple)) tokens.push(apple ? "⌃" : "Ctrl");
|
|
182
|
+
if (shortcut.alt) tokens.push(apple ? "⌥" : "Alt");
|
|
183
|
+
if (shortcut.shift) tokens.push(apple ? "⇧" : "Shift");
|
|
184
|
+
if (shortcut.meta || (shortcut.mod && apple)) tokens.push(apple ? "⌘" : "Win");
|
|
185
|
+
tokens.push(keyLabel(shortcut.key, apple));
|
|
186
|
+
return tokens;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** One string, for a `title`, an `aria-keyshortcuts` neighbour, or a menu with no room. */
|
|
190
|
+
export function shortcutText(spec: string | Shortcut, apple = isApplePlatform()): string {
|
|
191
|
+
return shortcutTokens(spec, apple).join(apple ? "" : "+");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const KEY_LABELS: Record<string, string> = {
|
|
195
|
+
" ": "Space",
|
|
196
|
+
escape: "Esc",
|
|
197
|
+
enter: "Enter",
|
|
198
|
+
backspace: "Backspace",
|
|
199
|
+
delete: "Del",
|
|
200
|
+
arrowup: "↑",
|
|
201
|
+
arrowdown: "↓",
|
|
202
|
+
arrowleft: "←",
|
|
203
|
+
arrowright: "→",
|
|
204
|
+
pageup: "PgUp",
|
|
205
|
+
pagedown: "PgDn"
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/** The glyphs an Apple keyboard prints on its own keys, which is what its menus show. */
|
|
209
|
+
const APPLE_KEY_LABELS: Record<string, string> = {
|
|
210
|
+
delete: "⌦",
|
|
211
|
+
backspace: "⌫",
|
|
212
|
+
enter: "↩",
|
|
213
|
+
escape: "esc"
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
function keyLabel(key: string, apple: boolean): string {
|
|
217
|
+
if (apple && APPLE_KEY_LABELS[key]) return APPLE_KEY_LABELS[key];
|
|
218
|
+
const named = KEY_LABELS[key];
|
|
219
|
+
if (named) return named;
|
|
220
|
+
// A function key is upper case whole (`F2`); a letter is one capital.
|
|
221
|
+
return key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* How long a typeahead buffer survives. Long enough to type a word, short enough to reset
|
|
226
|
+
* before the next thing you meant.
|
|
227
|
+
*/
|
|
228
|
+
export const TYPEAHEAD_MS = 600;
|
|
229
|
+
|
|
230
|
+
/** The buffer between two presses: what has been typed, and when. */
|
|
231
|
+
export interface TypeaheadState {
|
|
232
|
+
typed: string;
|
|
233
|
+
at: number;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface TypeaheadStep extends TypeaheadState {
|
|
237
|
+
/** What to look for. The buffer, or its one repeated character - see below. */
|
|
238
|
+
needle: string;
|
|
239
|
+
/**
|
|
240
|
+
* Whether this press CYCLES through the rows starting with the letter rather than refining
|
|
241
|
+
* what the last one found. True for a single letter and for the same letter pressed again,
|
|
242
|
+
* which is the rule every desktop list follows: "rrr" is not a word anybody is typing, it
|
|
243
|
+
* is someone walking through the Rs.
|
|
244
|
+
*/
|
|
245
|
+
cycle: boolean;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Advance a typeahead buffer.
|
|
250
|
+
*
|
|
251
|
+
* Shared because two lists in this package do the same thing with it - a select with no filter
|
|
252
|
+
* and a menu - and a buffer that timed out differently in the two would be felt as one of them
|
|
253
|
+
* being broken.
|
|
254
|
+
*/
|
|
255
|
+
export function typeaheadStep(state: TypeaheadState, character: string, now = Date.now(), windowMs = TYPEAHEAD_MS): TypeaheadStep {
|
|
256
|
+
const typed = now - state.at > windowMs ? character : state.typed + character;
|
|
257
|
+
const repeated = typed.length > 1 && [...typed].every((letter) => letter === typed[0]);
|
|
258
|
+
return {
|
|
259
|
+
typed,
|
|
260
|
+
at: now,
|
|
261
|
+
needle: repeated ? typed[0] : typed,
|
|
262
|
+
cycle: typed.length === 1 || repeated
|
|
263
|
+
};
|
|
264
|
+
}
|
package/src/core/search.ts
CHANGED
|
@@ -19,7 +19,12 @@ export interface SearchMatch<T> {
|
|
|
19
19
|
|
|
20
20
|
/** The shape of Fuse's constructor, declared here so the package need not depend on it. */
|
|
21
21
|
export interface FuseLike<T> {
|
|
22
|
-
|
|
22
|
+
/**
|
|
23
|
+
* Readonly arrays, because Fuse's own signature returns them - and a mutable array is
|
|
24
|
+
* not assignable to one, so the stricter shape made `fuse={Fuse}` a type error at the
|
|
25
|
+
* call site while working perfectly at runtime.
|
|
26
|
+
*/
|
|
27
|
+
search(query: string): readonly { item: T; score?: number; matches?: readonly { key?: string; }[]; }[];
|
|
23
28
|
}
|
|
24
29
|
export type FuseConstructor = new <T>(items: readonly T[], options?: Record<string, unknown>) => FuseLike<T>;
|
|
25
30
|
|
|
@@ -161,6 +166,25 @@ function subsequenceScore(query: string, text: string): number {
|
|
|
161
166
|
return score;
|
|
162
167
|
}
|
|
163
168
|
|
|
169
|
+
/**
|
|
170
|
+
* A query, short enough to put back on the screen.
|
|
171
|
+
*
|
|
172
|
+
* Every empty state quotes what was typed - "Nothing matches ..." - and what was typed is
|
|
173
|
+
* arbitrary: paste sixty characters with no spaces in them and there is no break opportunity
|
|
174
|
+
* in the whole string, so the panel grows to fit it and keeps growing. Cut in the TEXT and
|
|
175
|
+
* not only in CSS, because `text-overflow` needs a bounded box and the box is what the
|
|
176
|
+
* string is stretching.
|
|
177
|
+
*
|
|
178
|
+
* The tail is what identifies a typo, so the start is what survives.
|
|
179
|
+
*/
|
|
180
|
+
export function shortenQuery(query: string, max = 32): string {
|
|
181
|
+
const trimmed = query.trim();
|
|
182
|
+
if (trimmed.length <= max) return trimmed;
|
|
183
|
+
// A real ellipsis, not three dots: one character, and a screen reader says "ellipsis"
|
|
184
|
+
// rather than reading three full stops.
|
|
185
|
+
return `${trimmed.slice(0, max - 1).trimEnd()}\u2026`;
|
|
186
|
+
}
|
|
187
|
+
|
|
164
188
|
export function createSearch<T>(options: SearchOptions<T> = {}): SearchInstance<T> {
|
|
165
189
|
let opts: SearchOptions<T> = { ...options };
|
|
166
190
|
let items: readonly T[] = opts.items ?? [];
|