@enigmax/primitives 0.6.0 → 0.8.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/registry.json CHANGED
@@ -31,7 +31,7 @@
31
31
  "files": [
32
32
  {
33
33
  "path": "src/core/marquee.ts",
34
- "dest": "marquee.ts",
34
+ "dest": "marquee/marquee.ts",
35
35
  "targets": [
36
36
  "vanilla",
37
37
  "astro",
@@ -40,33 +40,34 @@
40
40
  },
41
41
  {
42
42
  "path": "src/react/use-marquee.ts",
43
- "dest": "use-marquee.ts",
43
+ "dest": "marquee/use-marquee.ts",
44
44
  "targets": [
45
45
  "react"
46
46
  ],
47
47
  "rewrite": {
48
48
  "@/core/marquee": "./marquee"
49
- }
49
+ },
50
+ "main": true
50
51
  },
51
52
  {
52
- "path": "recipes/marquee.tailwind.tsx",
53
- "dest": "Marquee.tsx",
53
+ "path": "recipes/marquee/tailwind.tsx",
54
+ "dest": "marquee/Marquee.tsx",
54
55
  "targets": [
55
56
  "react"
56
57
  ],
57
58
  "style": "tailwind"
58
59
  },
59
60
  {
60
- "path": "recipes/marquee.css.tsx",
61
- "dest": "Marquee.tsx",
61
+ "path": "recipes/marquee/css.tsx",
62
+ "dest": "marquee/Marquee.tsx",
62
63
  "targets": [
63
64
  "react"
64
65
  ],
65
66
  "style": "css"
66
67
  },
67
68
  {
68
- "path": "recipes/marquee.css",
69
- "dest": "marquee.css",
69
+ "path": "recipes/marquee/styles.css",
70
+ "dest": "marquee/styles.css",
70
71
  "targets": [
71
72
  "react",
72
73
  "vanilla",
@@ -92,8 +93,8 @@
92
93
  },
93
94
  {
94
95
  "name": "input",
95
- "title": "Input affordances",
96
- "description": "In-field buttons with no styles of their own. A password field gets its reveal toggle automatically; the icon, label, side and container are all replaceable, and any other action plugs into the same mechanism.",
96
+ "title": "Input, with what a password field needs",
97
+ "description": "A field you pass props to, with no styles of its own. A password gets its reveal toggle automatically; a generator, a strength meter and a breach check are one prop each and off until you ask. React renders every part of it, so nothing fights your tree.",
97
98
  "targets": [
98
99
  "vanilla",
99
100
  "astro",
@@ -112,13 +113,23 @@
112
113
  "createInput"
113
114
  ],
114
115
  "react": [
115
- "useInput"
116
+ "Input",
117
+ "PasswordStrength"
116
118
  ]
117
119
  },
118
120
  "files": [
119
121
  {
120
122
  "path": "src/core/input.ts",
121
- "dest": "input.ts",
123
+ "dest": "input/input.ts",
124
+ "targets": [
125
+ "vanilla",
126
+ "astro",
127
+ "react"
128
+ ]
129
+ },
130
+ {
131
+ "path": "src/core/password.ts",
132
+ "dest": "input/password.ts",
122
133
  "targets": [
123
134
  "vanilla",
124
135
  "astro",
@@ -127,7 +138,7 @@
127
138
  },
128
139
  {
129
140
  "path": "src/react/use-input.ts",
130
- "dest": "use-input.ts",
141
+ "dest": "input/use-input.ts",
131
142
  "targets": [
132
143
  "react"
133
144
  ],
@@ -136,40 +147,60 @@
136
147
  }
137
148
  },
138
149
  {
139
- "path": "recipes/input.tailwind.tsx",
140
- "dest": "Field.tsx",
150
+ "path": "recipes/input/styles.css",
151
+ "dest": "input/styles.css",
141
152
  "targets": [
142
- "react"
153
+ "react",
154
+ "vanilla",
155
+ "astro"
143
156
  ],
144
- "style": "tailwind"
157
+ "style": "css"
145
158
  },
146
159
  {
147
- "path": "recipes/input.css.tsx",
148
- "dest": "Field.tsx",
160
+ "path": "src/react/input.tsx",
161
+ "dest": "input/index.tsx",
162
+ "main": true,
149
163
  "targets": [
150
164
  "react"
151
165
  ],
152
- "style": "css"
166
+ "rewrite": {
167
+ "@/core/input": "./input",
168
+ "@/core/password": "./password"
169
+ }
153
170
  },
154
171
  {
155
- "path": "recipes/input.css",
156
- "dest": "input.css",
172
+ "path": "recipes/input/tailwind.tsx",
173
+ "dest": "input/PasswordField.tsx",
157
174
  "targets": [
158
- "react",
159
- "vanilla",
160
- "astro"
175
+ "react"
161
176
  ],
162
- "style": "css"
177
+ "style": "tailwind",
178
+ "rewrite": {
179
+ "@enigmax/primitives/react": "./index"
180
+ }
163
181
  }
164
182
  ],
165
183
  "styles": false,
166
184
  "themeHooks": [
185
+ "[data-enigma-input-root]",
186
+ "[data-enigma-input-root][data-revealed]",
187
+ "[data-enigma-input-root][data-breached]",
188
+ "[data-enigma-input-root][data-score]",
189
+ "[data-enigma-input-field]",
167
190
  "[data-enigma-input]",
168
191
  "[data-enigma-input][data-enigma-input-reveal=on]",
169
192
  "[data-enigma-input-actions]",
170
193
  "[data-enigma-input-actions][data-position=start]",
171
194
  "[data-enigma-input-action]",
172
- "[data-enigma-input-action=reveal]"
195
+ "[data-enigma-input-action=reveal]",
196
+ "[data-enigma-input-action=generate]",
197
+ "[data-enigma-password-strength]",
198
+ "[data-enigma-password-strength][data-empty]",
199
+ "[data-enigma-password-strength-track]",
200
+ "[data-enigma-password-strength-segment]",
201
+ "[data-enigma-password-strength-segment][data-filled]",
202
+ "[data-enigma-password-strength-label]",
203
+ "[data-enigma-password-strength-warning]"
173
204
  ],
174
205
  "docs": "docs/notes/primitives.md#input",
175
206
  "recipes": [
@@ -205,7 +236,7 @@
205
236
  "files": [
206
237
  {
207
238
  "path": "src/core/search.ts",
208
- "dest": "search.ts",
239
+ "dest": "search/search.ts",
209
240
  "targets": [
210
241
  "vanilla",
211
242
  "astro",
@@ -214,17 +245,18 @@
214
245
  },
215
246
  {
216
247
  "path": "src/react/use-search.ts",
217
- "dest": "use-search.ts",
248
+ "dest": "search/use-search.ts",
218
249
  "targets": [
219
250
  "react"
220
251
  ],
221
252
  "rewrite": {
222
253
  "@/core/search": "./search"
223
- }
254
+ },
255
+ "main": true
224
256
  },
225
257
  {
226
- "path": "recipes/search.tailwind.tsx",
227
- "dest": "Search.tsx",
258
+ "path": "recipes/search/tailwind.tsx",
259
+ "dest": "search/Search.tsx",
228
260
  "targets": [
229
261
  "react"
230
262
  ],
@@ -248,8 +280,8 @@
248
280
  },
249
281
  {
250
282
  "name": "button",
251
- "title": "Button behaviour",
252
- "description": "Disabled, loading, a keyboard shortcut and a cooldown that can outlive a reload, collapsed into one `available` the renderer reads. An href reports an anchor rather than a button, so the framework's own Link stays your choice.",
283
+ "title": "Button",
284
+ "description": "A button you pass props to. Disabled, loading, a keyboard shortcut and a cooldown that can outlive a reload collapse into one `available`; an href reports an anchor, so the framework's own Link stays your choice.",
253
285
  "targets": [
254
286
  "vanilla",
255
287
  "astro",
@@ -268,13 +300,14 @@
268
300
  "createButton"
269
301
  ],
270
302
  "react": [
303
+ "Button",
271
304
  "useButton"
272
305
  ]
273
306
  },
274
307
  "files": [
275
308
  {
276
309
  "path": "src/core/button.ts",
277
- "dest": "button.ts",
310
+ "dest": "button/button.ts",
278
311
  "targets": [
279
312
  "vanilla",
280
313
  "astro",
@@ -283,13 +316,25 @@
283
316
  },
284
317
  {
285
318
  "path": "src/react/use-button.ts",
286
- "dest": "use-button.ts",
319
+ "dest": "button/use-button.ts",
287
320
  "targets": [
288
321
  "react"
289
322
  ],
290
323
  "rewrite": {
291
324
  "@/core/button": "./button"
292
325
  }
326
+ },
327
+ {
328
+ "path": "src/react/button.tsx",
329
+ "dest": "button/index.tsx",
330
+ "main": true,
331
+ "targets": [
332
+ "react"
333
+ ],
334
+ "rewrite": {
335
+ "@/core/button": "./button",
336
+ "@/react/use-button": "./use-button"
337
+ }
293
338
  }
294
339
  ],
295
340
  "styles": false,
package/src/core/input.ts CHANGED
@@ -72,9 +72,32 @@ export interface InputInstance {
72
72
  destroy(): void;
73
73
  }
74
74
 
75
- /** Neutral 1em glyphs that inherit `color`. Replace them with anything. */
76
- const EYE = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z"/><circle cx="12" cy="12" r="3"/></svg>';
77
- const EYE_OFF = '<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9"/><path d="m2 2 20 20"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg>';
75
+ /**
76
+ * The built-in glyphs, as path data.
77
+ *
78
+ * Path data rather than markup because there are two renderers: this file writes an SVG
79
+ * string into a button it created, and the React component builds elements. Keeping the
80
+ * shapes here means one definition, and a theme that replaces an icon replaces it in both.
81
+ * Everything is stroked with `currentColor` at 1em, so an icon inherits the field's text.
82
+ */
83
+ export const INPUT_ICON_PATHS = {
84
+ eye: ["M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7Z", "M15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0Z"],
85
+ eyeOff: [
86
+ "M10.6 5.2A9.8 9.8 0 0 1 12 5c6.5 0 10 7 10 7a17.6 17.6 0 0 1-3.2 4.2M6.2 6.2A17.7 17.7 0 0 0 2 12s3.5 7 10 7a9.6 9.6 0 0 0 4.2-.9",
87
+ "m2 2 20 20",
88
+ "M9.9 9.9a3 3 0 0 0 4.2 4.2"
89
+ ],
90
+ generate: ["m12 3 1.9 4.6L18.5 9.5 13.9 11.4 12 16l-1.9-4.6L5.5 9.5l4.6-1.9Z", "M19 15l.8 2.2 2.2.8-2.2.8-.8 2.2-.8-2.2-2.2-.8 2.2-.8Z"]
91
+ } as const;
92
+
93
+ /** The same shapes as a standalone SVG string, for the DOM renderer below. */
94
+ export function iconMarkup(paths: readonly string[]): string {
95
+ const body = paths.map((path) => `<path d="${path}"/>`).join("");
96
+ return `<svg viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${body}</svg>`;
97
+ }
98
+
99
+ const EYE = iconMarkup(INPUT_ICON_PATHS.eye);
100
+ const EYE_OFF = iconMarkup(INPUT_ICON_PATHS.eyeOff);
78
101
 
79
102
  function resolve<T>(value: T | ((state: InputActionState) => T), state: InputActionState): T {
80
103
  return typeof value === "function" ? (value as (state: InputActionState) => T)(state) : value;
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Making a password, and judging one.
3
+ *
4
+ * Both are opt-in. A sign-in form wants neither: offering to generate a password where one
5
+ * already exists is noise, and scoring one the visitor cannot change is worse. They belong
6
+ * on a registration form and a change-password form, which is where an agent should switch
7
+ * them on.
8
+ */
9
+
10
+ /** Character classes a generated password can draw from. */
11
+ export interface PasswordAlphabet {
12
+ lowercase?: boolean;
13
+ uppercase?: boolean;
14
+ digits?: boolean;
15
+ symbols?: boolean;
16
+ }
17
+
18
+ export interface GeneratePasswordOptions extends PasswordAlphabet {
19
+ /** Default 20. Long beats clever: length is the only term that scales. */
20
+ length?: number;
21
+ /**
22
+ * Drop the characters that are read wrong off a screen or off paper - I l 1 O 0.
23
+ * Worth it when the password will be typed by hand, not worth the entropy otherwise.
24
+ */
25
+ excludeAmbiguous?: boolean;
26
+ /** Characters to remove from every class, e.g. ones your backend rejects. */
27
+ exclude?: string;
28
+ /**
29
+ * Guarantee at least one character from every class asked for. Most password policies
30
+ * demand it; it costs a little entropy, because it removes every password that happens
31
+ * to lack one.
32
+ */
33
+ requireEachClass?: boolean;
34
+ }
35
+
36
+ const LOWERCASE = "abcdefghijklmnopqrstuvwxyz";
37
+ const UPPERCASE = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
38
+ const DIGITS = "0123456789";
39
+ /**
40
+ * No quotes, backslash, backtick or space: those are the characters that get mangled on the
41
+ * way through a shell, a CSV, a JSON blob written by hand, or a copy out of a terminal.
42
+ */
43
+ const SYMBOLS = "!@#$%^&*()-_=+[]{};:,.?";
44
+ const AMBIGUOUS = "Il1O0";
45
+
46
+ /**
47
+ * Uniform in [0, bound), by rejection.
48
+ *
49
+ * `value % bound` is the version everyone writes and it is biased: 2^32 is not a multiple
50
+ * of most bounds, so the first few characters of the alphabet come up slightly more often.
51
+ * On a password that is a real, if small, loss of entropy, and it costs one comparison to
52
+ * avoid.
53
+ */
54
+ function randomIndex(bound: number): number {
55
+ const random = globalThis.crypto?.getRandomValues?.bind(globalThis.crypto);
56
+ if (!random) {
57
+ // Never Math.random. A generator that silently produces predictable passwords is
58
+ // worse than one that refuses: nothing downstream can tell the difference.
59
+ throw new Error("Generating a password needs crypto.getRandomValues, which browsers only expose over HTTPS (or on localhost).");
60
+ }
61
+ const limit = Math.floor(2 ** 32 / bound) * bound;
62
+ const buffer = new Uint32Array(1);
63
+ let value: number;
64
+ do {
65
+ random(buffer);
66
+ value = buffer[0];
67
+ } while (value >= limit);
68
+ return value % bound;
69
+ }
70
+
71
+ function pick(alphabet: string): string {
72
+ return alphabet[randomIndex(alphabet.length)];
73
+ }
74
+
75
+ function classes(options: GeneratePasswordOptions): string[] {
76
+ const { lowercase = true, uppercase = true, digits = true, symbols = true, excludeAmbiguous = false, exclude = "" } = options;
77
+ const banned = new Set([...(excludeAmbiguous ? AMBIGUOUS : ""), ...exclude]);
78
+ const clean = (source: string): string => [...source].filter((character) => !banned.has(character)).join("");
79
+
80
+ return [
81
+ lowercase ? clean(LOWERCASE) : "",
82
+ uppercase ? clean(UPPERCASE) : "",
83
+ digits ? clean(DIGITS) : "",
84
+ symbols ? clean(SYMBOLS) : ""
85
+ ].filter(Boolean);
86
+ }
87
+
88
+ /** Fisher-Yates, with the same unbiased source. A biased shuffle undoes a fair draw. */
89
+ function shuffle(characters: string[]): string[] {
90
+ for (let index = characters.length - 1; index > 0; index--) {
91
+ const swap = randomIndex(index + 1);
92
+ [characters[index], characters[swap]] = [characters[swap], characters[index]];
93
+ }
94
+ return characters;
95
+ }
96
+
97
+ /**
98
+ * A random password from the classes asked for.
99
+ *
100
+ * @throws when the runtime has no CSPRNG, or when the options ask for something impossible
101
+ * (every class excluded, or a length too short to hold one of each).
102
+ */
103
+ export function generatePassword(options: GeneratePasswordOptions = {}): string {
104
+ const { length = 20, requireEachClass = true } = options;
105
+ const pools = classes(options);
106
+ if (!pools.length) throw new Error("generatePassword: every character class was excluded.");
107
+ if (length < 1) throw new Error("generatePassword: length must be at least 1.");
108
+ if (requireEachClass && length < pools.length) {
109
+ throw new Error(`generatePassword: length ${length} cannot hold one character from each of the ${pools.length} classes requested.`);
110
+ }
111
+
112
+ const everything = pools.join("");
113
+ // One from each class first, the rest uniform, then shuffled - so the guarantee does
114
+ // not put the digit in a predictable place.
115
+ const required = requireEachClass ? pools.map(pick) : [];
116
+ const rest = Array.from({ length: length - required.length }, () => pick(everything));
117
+ return shuffle([...required, ...rest]).join("");
118
+ }
119
+
120
+ export type PasswordScore = 0 | 1 | 2 | 3 | 4;
121
+
122
+ export interface PasswordStrengthReport {
123
+ /** 0 worst, 4 best. What the bars under the field render. */
124
+ score: PasswordScore;
125
+ /** Estimated bits of entropy after the penalties below. */
126
+ bits: number;
127
+ /**
128
+ * Why it scored what it scored, worst first. Show the first one; showing all of them
129
+ * turns a hint into a lecture.
130
+ */
131
+ warnings: string[];
132
+ /** Empty field. Render nothing rather than a zero score, which reads as a failure. */
133
+ empty: boolean;
134
+ }
135
+
136
+ export interface EstimateOptions {
137
+ /**
138
+ * Values the visitor has already typed elsewhere - email, name, company. A password
139
+ * containing one of them is guessable by anyone who has the sign-up form in front of
140
+ * them, and no character-class rule catches it.
141
+ */
142
+ userInputs?: string[];
143
+ }
144
+
145
+ /**
146
+ * The forty or so passwords that turn up at the top of every breach corpus. Not a
147
+ * dictionary: it is here to catch `password1`, not to be exhaustive. For real coverage
148
+ * either replace the estimator with zxcvbn, or check the breach corpus - which is what
149
+ * `checkPasswordBreach` in @enigmax/utils is for.
150
+ */
151
+ const COMMON = new Set([
152
+ "password", "passwd", "123456", "12345678", "123456789", "1234567890", "qwerty", "qwertyuiop",
153
+ "abc123", "111111", "123123", "admin", "letmein", "welcome", "monkey", "dragon", "sunshine",
154
+ "iloveyou", "princess", "football", "baseball", "master", "shadow", "superman", "batman",
155
+ "trustno1", "hello", "freedom", "whatever", "starwars", "changeme", "secret", "login",
156
+ "root", "toor", "test", "guest", "azerty", "1q2w3e4r", "zaq12wsx"
157
+ ]);
158
+
159
+ const SEQUENCES = ["abcdefghijklmnopqrstuvwxyz", "0123456789", "qwertyuiop", "asdfghjkl", "zxcvbnm"];
160
+
161
+ /** The pool an attacker would have to search, from the classes actually used. */
162
+ function poolSize(password: string): number {
163
+ let size = 0;
164
+ if (/[a-z]/.test(password)) size += 26;
165
+ if (/[A-Z]/.test(password)) size += 26;
166
+ if (/\d/.test(password)) size += 10;
167
+ if (/[^\w\s]|_/.test(password)) size += SYMBOLS.length;
168
+ if (/\s/.test(password)) size += 1;
169
+ return size || 1;
170
+ }
171
+
172
+ /** Longest run of the same character, and longest run along a keyboard or alphabet line. */
173
+ function longestRun(password: string): number {
174
+ let longest = 1, run = 1;
175
+ for (let index = 1; index < password.length; index++) {
176
+ run = password[index] === password[index - 1] ? run + 1 : 1;
177
+ longest = Math.max(longest, run);
178
+ }
179
+ return longest;
180
+ }
181
+
182
+ function longestSequence(password: string): number {
183
+ const lower = password.toLowerCase();
184
+ let longest = 0;
185
+ for (const line of SEQUENCES) {
186
+ const reversed = [...line].reverse().join("");
187
+ for (const source of [line, reversed]) {
188
+ for (let start = 0; start < source.length; start++) {
189
+ for (let end = source.length; end > start + longest; end--) {
190
+ if (lower.includes(source.slice(start, end))) {
191
+ longest = Math.max(longest, end - start);
192
+ break;
193
+ }
194
+ }
195
+ }
196
+ }
197
+ }
198
+ return longest;
199
+ }
200
+
201
+ /** Strip the decoration people add to satisfy a policy: Password1! is password. */
202
+ function core(password: string): string {
203
+ return password.toLowerCase().replace(/^[^a-z]+/, "").replace(/[^a-z]+$/, "").replace(/[0!@$]/g, (character) => ({ "0": "o", "!": "i", "@": "a", "$": "s" })[character] ?? character);
204
+ }
205
+
206
+ /**
207
+ * Score a password.
208
+ *
209
+ * The bits are an estimate and the bands are a convention, not a measurement - they exist
210
+ * to move a bar, not to certify anything. Swap this out for zxcvbn where the number has to
211
+ * mean something, and check the breach corpus for the cases no estimator can see.
212
+ */
213
+ export function estimatePasswordStrength(password: string, options: EstimateOptions = {}): PasswordStrengthReport {
214
+ if (!password) return { score: 0, bits: 0, warnings: [], empty: true };
215
+
216
+ const warnings: string[] = [];
217
+ let bits = password.length * Math.log2(poolSize(password));
218
+
219
+ const stripped = core(password);
220
+ if (COMMON.has(password.toLowerCase()) || COMMON.has(stripped)) {
221
+ // A password on every list has no entropy at all, whatever its shape.
222
+ bits = Math.min(bits, 8);
223
+ warnings.push("This is one of the most common passwords there is.");
224
+ }
225
+
226
+ for (const input of options.userInputs ?? []) {
227
+ const needle = input.trim().toLowerCase();
228
+ // A three-letter name matches half the passwords in the world; ignore short ones.
229
+ if (needle.length < 4 || !password.toLowerCase().includes(needle)) continue;
230
+ bits = Math.min(bits, 16);
231
+ warnings.push("It contains something you already typed on this form.");
232
+ break;
233
+ }
234
+
235
+ const run = longestRun(password);
236
+ if (run >= 3) {
237
+ bits -= (run - 2) * Math.log2(poolSize(password));
238
+ warnings.push("A character repeats several times in a row.");
239
+ }
240
+
241
+ const sequence = longestSequence(password);
242
+ if (sequence >= 4) {
243
+ bits -= sequence * Math.log2(poolSize(password)) * 0.75;
244
+ warnings.push("Part of it runs straight along the keyboard or the alphabet.");
245
+ }
246
+
247
+ if (password.length < 8) warnings.push("Short passwords fall to a brute force whatever they contain.");
248
+
249
+ bits = Math.max(0, Math.round(bits));
250
+ const score: PasswordScore = bits < 28 ? 0 : bits < 40 ? 1 : bits < 60 ? 2 : bits < 80 ? 3 : 4;
251
+ return { score, bits, warnings, empty: false };
252
+ }
package/src/index.ts CHANGED
@@ -2,3 +2,13 @@ export { createMarquee, type MarqueeOptions, type MarqueeInstance, type MarqueeH
2
2
  export { createInput, type InputOptions, type InputInstance, type InputAction, type InputIcon, type InputActionState } from "@/core/input";
3
3
  export { createSearch, type SearchOptions, type SearchInstance, type SearchMatch, type FuseConstructor, type FuseLike } from "@/core/search";
4
4
  export { createButton, type ButtonOptions, type ButtonInstance, type ButtonState, type ButtonElement, type ButtonCooldown } from "@/core/button";
5
+ export { INPUT_ICON_PATHS, iconMarkup } from "@/core/input";
6
+ export {
7
+ generatePassword,
8
+ estimatePasswordStrength,
9
+ type GeneratePasswordOptions,
10
+ type PasswordAlphabet,
11
+ type EstimateOptions,
12
+ type PasswordStrengthReport,
13
+ type PasswordScore
14
+ } from "@/core/password";
@@ -0,0 +1,80 @@
1
+ "use client";
2
+
3
+ import { useButton } from "@/react/use-button";
4
+ import type { ButtonOptions, ButtonState } from "@/core/button";
5
+ import { createElement, forwardRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
6
+
7
+ /**
8
+ * `<Button>` - the component, for the ninety percent.
9
+ *
10
+ * ```tsx
11
+ * <Button onPress={save}>Save</Button>
12
+ * ```
13
+ *
14
+ * That is the whole thing: disabled while the work runs, `aria-busy`, `data-loading`, and a
15
+ * cooldown afterwards if you asked for one. `useButton` is still there for a button whose
16
+ * markup is nothing like a button - a card, a table row - but reaching for it to render an
17
+ * ordinary one means writing the same six lines at every call site, which is how they drift.
18
+ */
19
+
20
+ // `onChange` is omitted from the DOM side because the primitive's own onChange - which
21
+ // reports the button's state - would otherwise clash with the form event of the same name.
22
+ export interface ButtonProps extends ButtonOptions, Omit<ComponentPropsWithoutRef<"button">, "onClick" | "onChange" | "children" | "type"> {
23
+ /**
24
+ * What to render. Defaults to `a` when there is an href and `button` otherwise, which
25
+ * is what you want until a router is involved - then pass its Link and keep the rest.
26
+ *
27
+ * ```tsx
28
+ * <Button as={Link} href="/settings">Settings</Button>
29
+ * ```
30
+ */
31
+ as?: ElementType;
32
+ /**
33
+ * The label, or a function of the state for one that changes with it.
34
+ *
35
+ * ```tsx
36
+ * <Button onPress={send} cooldown={30_000}>
37
+ * {({ loading, cooldown }) => loading ? "Sending" : cooldown ? `Wait ${Math.ceil(cooldown / 1000)}s` : "Resend"}
38
+ * </Button>
39
+ * ```
40
+ */
41
+ children?: ReactNode | ((state: ButtonState) => ReactNode);
42
+ /** Replaces the label while async work runs - a spinner, or just different words. */
43
+ pending?: ReactNode;
44
+ /** `type` on a real button. Ignored on a link, which has none. */
45
+ type?: "button" | "submit" | "reset";
46
+ }
47
+
48
+ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
49
+ as,
50
+ children,
51
+ pending,
52
+ href,
53
+ disabled,
54
+ loading,
55
+ cooldown,
56
+ shortcut,
57
+ onPress,
58
+ onChange,
59
+ type = "button",
60
+ ...rest
61
+ }, ref) {
62
+ const button = useButton({ href, disabled, loading, cooldown, shortcut, onPress, onChange });
63
+ const state: ButtonState = button;
64
+
65
+ // Reported, not chosen: an href makes it an anchor, and a package that cannot import
66
+ // next/link must not be the thing deciding. `as` overrides both.
67
+ const Tag: ElementType = as ?? state.element;
68
+
69
+ // `type="button"` is the default on purpose. A bare <button> inside a form submits it,
70
+ // so an action button that forgot it posts the form instead of doing its job.
71
+ const native = Tag === "button" ? { type } : {};
72
+
73
+ const label = typeof children === "function" ? children(state) : children;
74
+
75
+ return createElement(
76
+ Tag,
77
+ { ref, ...button.props, ...native, ...rest },
78
+ state.loading && pending !== undefined ? pending : label
79
+ );
80
+ });
@@ -1,3 +1,5 @@
1
+ "use client";
2
+
1
3
  export { useMarquee, type UseMarqueeResult } from "@/react/use-marquee";
2
4
  export { type MarqueeOptions, type MarqueeInstance, type MarqueeHover } from "@/core/marquee";
3
5
  export { useInput, type UseInputResult } from "@/react/use-input";
@@ -5,4 +7,23 @@ export { useSearch, type UseSearchResult } from "@/react/use-search";
5
7
  export { type InputOptions, type InputAction, type InputIcon } from "@/core/input";
6
8
  export { type SearchOptions, type SearchMatch, type FuseConstructor } from "@/core/search";
7
9
  export { useButton, type UseButtonResult } from "@/react/use-button";
10
+ export { Button, type ButtonProps } from "@/react/button";
8
11
  export { type ButtonOptions, type ButtonState } from "@/core/button";
12
+ export {
13
+ Input,
14
+ PasswordStrength,
15
+ type InputProps,
16
+ type PasswordStrengthProps,
17
+ type FieldAction,
18
+ type BreachChecker,
19
+ type BreachState,
20
+ type BreachStatus
21
+ } from "@/react/input";
22
+ export {
23
+ generatePassword,
24
+ estimatePasswordStrength,
25
+ type GeneratePasswordOptions,
26
+ type EstimateOptions,
27
+ type PasswordStrengthReport,
28
+ type PasswordScore
29
+ } from "@/core/password";