@enigmax/primitives 0.16.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/dist/button-CaXaqG_K.d.ts +63 -0
- package/dist/chunk-2QFTRNAZ.js +88 -0
- package/dist/chunk-6BGBYUSZ.js +114 -0
- package/dist/chunk-AU3H5WIY.js +107 -0
- package/dist/chunk-F25CGNQC.js +19 -0
- package/dist/chunk-HC2ME5PU.js +168 -0
- package/dist/chunk-HS3X3XCW.js +43 -0
- package/dist/chunk-IIT6U7LM.js +249 -0
- package/dist/chunk-IXVMRVD4.js +96 -0
- package/dist/chunk-KJINGUQN.js +188 -0
- package/dist/chunk-LKA2UG4P.js +542 -0
- package/dist/chunk-MMQPZGSU.js +161 -0
- package/dist/chunk-OCMI7R6H.js +79 -0
- package/dist/chunk-QYMUIW5I.js +28 -0
- package/dist/chunk-S653GLSF.js +17 -0
- package/dist/chunk-SNYUBXWQ.js +149 -0
- package/dist/chunk-U3V4EHOB.js +41 -0
- package/dist/chunk-UOSSNUSC.js +309 -0
- package/dist/chunk-WSB655JB.js +47 -0
- package/dist/chunk-XNNQRA35.js +31 -0
- package/dist/chunk-XQHCZAPJ.js +102 -0
- package/dist/chunk-ZCUFYBPB.js +154 -0
- package/dist/chunk-ZWR2EXHQ.js +55 -0
- package/dist/flags-BBJc9unY.d.ts +133 -0
- package/dist/index-D-ARvWpQ.d.ts +306 -0
- package/dist/index-DNXPtY9X.d.ts +144 -0
- package/dist/index.d.ts +16 -496
- package/dist/index.js +9 -1
- package/dist/input-BwXjFenq.d.ts +77 -0
- package/dist/marquee-CJ3Uwy3E.d.ts +81 -0
- package/dist/network-D2LsBG_k.d.ts +39 -0
- package/dist/next/index.d.ts +20 -3
- package/dist/next/index.js +23 -3
- package/dist/notifications-BpVV6sel.d.ts +70 -0
- package/dist/password-3DRQYAYQ.js +2 -0
- package/dist/password-C8lG4Zm9.d.ts +71 -0
- package/dist/password-FB2CUEKJ.js +1 -0
- package/dist/react/button.d.ts +75 -0
- package/dist/react/button.js +4 -0
- package/dist/react/flag.d.ts +37 -0
- package/dist/react/flag.js +3 -0
- package/dist/react/index.d.ts +28 -286
- package/dist/react/index.js +22 -2
- package/dist/react/input.d.ts +4 -0
- package/dist/react/input.js +3 -0
- package/dist/react/marquee.d.ts +44 -0
- package/dist/react/marquee.js +3 -0
- package/dist/react/network.d.ts +20 -0
- package/dist/react/network.js +3 -0
- package/dist/react/notifications.d.ts +17 -0
- package/dist/react/notifications.js +3 -0
- package/dist/react/palette.d.ts +3 -0
- package/dist/react/palette.js +4 -0
- package/dist/react/relative-time.d.ts +21 -0
- package/dist/react/relative-time.js +3 -0
- package/dist/react/search.d.ts +30 -0
- package/dist/react/search.js +3 -0
- package/dist/react/slot.d.ts +47 -0
- package/dist/react/slot.js +2 -0
- package/dist/react/toast.d.ts +40 -0
- package/dist/react/toast.js +4 -0
- package/dist/react-router/index.d.ts +20 -3
- package/dist/react-router/index.js +23 -3
- package/dist/relative-time-YpRTG7YH.d.ts +106 -0
- package/dist/search-P4OHCRXD.js +50 -0
- package/package.json +53 -1
- package/registry.json +235 -16
- package/src/core/flags.ts +266 -0
- package/src/core/input-icons.ts +34 -0
- package/src/core/input.ts +5 -21
- package/src/core/palette.ts +0 -0
- package/src/index.ts +18 -0
- package/src/react/button.tsx +70 -4
- package/src/react/flag.tsx +70 -0
- package/src/react/index.ts +52 -5
- package/src/react/input/icon.tsx +17 -0
- package/src/react/input/index.tsx +307 -0
- package/src/react/input/password.tsx +174 -0
- package/src/react/input/search.tsx +82 -0
- package/src/react/input/types.ts +146 -0
- package/src/react/input/write-value.ts +18 -0
- package/src/react/palette/context.ts +60 -0
- package/src/react/palette/index.tsx +66 -0
- package/src/react/palette/root.tsx +621 -0
- package/src/react/slot.tsx +91 -0
- package/src/react/use-button.ts +3 -1
- package/dist/chunk-53DQY6LP.js +0 -1164
- package/dist/chunk-U2KYYMBJ.js +0 -724
- package/src/react/input.tsx +0 -429
package/src/index.ts
CHANGED
|
@@ -27,3 +27,21 @@ export {
|
|
|
27
27
|
type RelativeTimePrecision,
|
|
28
28
|
type RelativeTimeStyle
|
|
29
29
|
} from "@/core/relative-time";
|
|
30
|
+
export {
|
|
31
|
+
flagSrc,
|
|
32
|
+
flagView,
|
|
33
|
+
flagAttributes,
|
|
34
|
+
flagName,
|
|
35
|
+
normalizeFlagCode,
|
|
36
|
+
configureFlags,
|
|
37
|
+
flagConfig,
|
|
38
|
+
resetFlagConfig,
|
|
39
|
+
FLAG_RATIO,
|
|
40
|
+
FLAG_CDN,
|
|
41
|
+
type FlagOptions,
|
|
42
|
+
type FlagConfig,
|
|
43
|
+
type FlagView,
|
|
44
|
+
type FlagShape,
|
|
45
|
+
type FlagFormat,
|
|
46
|
+
type FlagSource
|
|
47
|
+
} from "@/core/flags";
|
package/src/react/button.tsx
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
+
import { Slot } from "@/react/slot";
|
|
3
4
|
import { useButton } from "@/react/use-button";
|
|
4
5
|
import { getLinkComponent } from "@/react/link";
|
|
5
6
|
import type { ButtonOptions, ButtonState } from "@/core/button";
|
|
6
|
-
import { createElement, forwardRef, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
|
|
7
|
+
import { createElement, forwardRef, isValidElement, type ComponentPropsWithoutRef, type ElementType, type ReactNode } from "react";
|
|
7
8
|
|
|
8
9
|
/**
|
|
9
10
|
* `<Button>` - the component, for the ninety percent.
|
|
@@ -51,6 +52,49 @@ export interface ButtonProps extends ButtonOptions, Omit<ComponentPropsWithoutRe
|
|
|
51
52
|
pending?: ReactNode;
|
|
52
53
|
/** `type` on a real button. Ignored on a link, which has none. */
|
|
53
54
|
type?: "button" | "submit" | "reset";
|
|
55
|
+
/**
|
|
56
|
+
* Show the shortcut as a key badge after the label, the way Stripe writes
|
|
57
|
+
* "Create invoice n".
|
|
58
|
+
*
|
|
59
|
+
* `"auto"` (the default) shows it when there is a shortcut AND the label renders text:
|
|
60
|
+
* beside a lone glyph on an icon-only button the badge is noise, and it would double the
|
|
61
|
+
* width of the smallest control on the screen. `true` and `false` decide it outright.
|
|
62
|
+
*/
|
|
63
|
+
shortcutHint?: boolean | "auto";
|
|
64
|
+
/** Render the badge yourself. Return null to drop it for one button. */
|
|
65
|
+
renderShortcut?: (key: string) => ReactNode;
|
|
66
|
+
/**
|
|
67
|
+
* Put the behaviour on YOUR element instead of ours - a `motion.button`, your design
|
|
68
|
+
* system's button, a router Link, anything.
|
|
69
|
+
*
|
|
70
|
+
* ```tsx
|
|
71
|
+
* <Button asChild cooldown={30_000} onPress={resend}>
|
|
72
|
+
* <motion.button whileTap={{ scale: 0.98 }}>Resend</motion.button>
|
|
73
|
+
* </Button>
|
|
74
|
+
* ```
|
|
75
|
+
*
|
|
76
|
+
* The child owns its own markup, so the shortcut badge is not injected - the child is
|
|
77
|
+
* rendered exactly as written. Read `state.shortcut` from `useButton` to place your own.
|
|
78
|
+
*/
|
|
79
|
+
asChild?: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Whether a label renders any TEXT.
|
|
84
|
+
*
|
|
85
|
+
* Walked rather than assumed: `<Button shortcut="s"><Icon /></Button>` and
|
|
86
|
+
* `<Button shortcut="s"><Icon /> Save</Button>` differ only in a string buried in the tree,
|
|
87
|
+
* and that string is the whole difference between a hint and a decoration. An element whose
|
|
88
|
+
* text arrives from somewhere this cannot see reports false, so `auto` errs towards the
|
|
89
|
+
* quieter button; `shortcutHint` overrides it in one word.
|
|
90
|
+
*/
|
|
91
|
+
function hasText(node: ReactNode): boolean {
|
|
92
|
+
if (node === null || node === undefined || typeof node === "boolean") return false;
|
|
93
|
+
if (typeof node === "string") return node.trim().length > 0;
|
|
94
|
+
if (typeof node === "number") return true;
|
|
95
|
+
if (Array.isArray(node)) return node.some(hasText);
|
|
96
|
+
if (isValidElement(node)) return hasText((node.props as { children?: ReactNode; }).children);
|
|
97
|
+
return false;
|
|
54
98
|
}
|
|
55
99
|
|
|
56
100
|
export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
|
|
@@ -66,6 +110,9 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
|
|
|
66
110
|
onClick,
|
|
67
111
|
onChange,
|
|
68
112
|
type = "button",
|
|
113
|
+
shortcutHint = "auto",
|
|
114
|
+
renderShortcut,
|
|
115
|
+
asChild = false,
|
|
69
116
|
...rest
|
|
70
117
|
}, ref) {
|
|
71
118
|
const button = useButton({ href, disabled, loading, cooldown, shortcut, onPress: onPress ?? onClick, onChange });
|
|
@@ -75,17 +122,36 @@ export const Button = forwardRef<HTMLElement, ButtonProps>(function Button({
|
|
|
75
122
|
// to setLinkComponent - and a plain <a> when nothing was. The package cannot import a
|
|
76
123
|
// router itself, so registering one is what turns every href into a client navigation
|
|
77
124
|
// without a word at the call site.
|
|
78
|
-
const Tag: ElementType = as ?? (state.element === "a" ? getLinkComponent() : "button");
|
|
125
|
+
const Tag: ElementType = asChild ? Slot : as ?? (state.element === "a" ? getLinkComponent() : "button");
|
|
79
126
|
|
|
80
127
|
// `type="button"` is the default on purpose. A bare <button> inside a form submits it,
|
|
81
|
-
// so an action button that forgot it posts the form instead of doing its job.
|
|
128
|
+
// so an action button that forgot it posts the form instead of doing its job. Never
|
|
129
|
+
// injected through a slot: the child may be an anchor or a div, where `type` is either
|
|
130
|
+
// invalid or means something else entirely.
|
|
82
131
|
const native = Tag === "button" ? { type } : {};
|
|
83
132
|
|
|
84
133
|
const label = typeof children === "function" ? children(state) : children;
|
|
134
|
+
const content = state.loading && pending !== undefined ? pending : label;
|
|
135
|
+
|
|
136
|
+
// Not while it is working: the label has already been replaced by whatever `pending`
|
|
137
|
+
// says, and a key that does nothing right now is not a hint.
|
|
138
|
+
// Never through a slot: it takes exactly one child, and a second one would throw.
|
|
139
|
+
const showHint = !asChild && Boolean(state.shortcut) && !state.loading &&
|
|
140
|
+
(shortcutHint === "auto" ? hasText(content) : shortcutHint);
|
|
141
|
+
const hint = showHint && state.shortcut
|
|
142
|
+
? renderShortcut?.(state.shortcut) ?? createElement(
|
|
143
|
+
"kbd",
|
|
144
|
+
// Hidden from the accessible name - the button already carries
|
|
145
|
+
// `aria-keyshortcuts`, and "Save n" is not what anyone wants read out.
|
|
146
|
+
{ "data-enigma-button-key": "", "aria-hidden": true },
|
|
147
|
+
state.shortcut
|
|
148
|
+
)
|
|
149
|
+
: null;
|
|
85
150
|
|
|
86
151
|
return createElement(
|
|
87
152
|
Tag,
|
|
88
153
|
{ ref, ...button.props, ...native, ...rest },
|
|
89
|
-
|
|
154
|
+
content,
|
|
155
|
+
hint
|
|
90
156
|
);
|
|
91
157
|
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { type ImgHTMLAttributes, type ReactNode } from "react";
|
|
4
|
+
import { flagView, type FlagOptions, type FlagShape, type FlagFormat, type FlagSource } from "@/core/flags";
|
|
5
|
+
|
|
6
|
+
export interface FlagProps extends Omit<ImgHTMLAttributes<HTMLImageElement>, "src" | "alt" | "width" | "height"> {
|
|
7
|
+
/** `es`, `ES`, `en-GB`, `gb-eng`, or the emoji flag you are replacing. */
|
|
8
|
+
code: string | null | undefined;
|
|
9
|
+
/**
|
|
10
|
+
* Your own accessible name. Left out, the country's own name is used, in the reader's
|
|
11
|
+
* language - so a flag standing on its own is never a silent image.
|
|
12
|
+
*/
|
|
13
|
+
label?: string;
|
|
14
|
+
/**
|
|
15
|
+
* Beside a country name that is already on screen the flag is decoration, and repeating
|
|
16
|
+
* the name only makes a screen reader say it twice.
|
|
17
|
+
*/
|
|
18
|
+
decorative?: boolean;
|
|
19
|
+
/** Language the automatic name is written in. Follows the document's `lang` otherwise. */
|
|
20
|
+
locale?: string;
|
|
21
|
+
shape?: FlagShape;
|
|
22
|
+
format?: FlagFormat;
|
|
23
|
+
source?: FlagSource;
|
|
24
|
+
basePath?: string;
|
|
25
|
+
/** Rendered height in px. The width follows the shape. Default 16. */
|
|
26
|
+
size?: number;
|
|
27
|
+
/** Rendered when the code resolves to nothing. Nothing, by default. */
|
|
28
|
+
fallback?: ReactNode;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `<Flag code="es" />` - a country flag as an image, at the size you asked for, from the
|
|
33
|
+
* source the app configured once.
|
|
34
|
+
*
|
|
35
|
+
* The defaults come from `configureFlags`, so moving the whole application from the CDN to
|
|
36
|
+
* a downloaded set is one line at startup and no change at any call site.
|
|
37
|
+
*/
|
|
38
|
+
export function Flag({ code, label, decorative = false, locale, shape, format, source, basePath, size, fallback = null, ...rest }: FlagProps): ReactNode {
|
|
39
|
+
const options: FlagOptions = { shape, format, source, basePath, size, locale };
|
|
40
|
+
// An explicit `undefined` prop must not beat the configured default, so the unset ones
|
|
41
|
+
// are dropped rather than spread over the config.
|
|
42
|
+
for (const key of Object.keys(options) as (keyof FlagOptions)[]) {
|
|
43
|
+
if (options[key] === undefined) delete options[key];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const view = flagView(code, options);
|
|
47
|
+
if (!view) return fallback;
|
|
48
|
+
|
|
49
|
+
// Automatic unless told otherwise: the country's own name, in the reader's language.
|
|
50
|
+
const name = decorative ? "" : label?.trim() || view.name || "";
|
|
51
|
+
return (
|
|
52
|
+
<img
|
|
53
|
+
src={view.src}
|
|
54
|
+
alt={name}
|
|
55
|
+
width={view.width}
|
|
56
|
+
height={view.height}
|
|
57
|
+
loading="lazy"
|
|
58
|
+
decoding="async"
|
|
59
|
+
aria-hidden={name ? undefined : true}
|
|
60
|
+
// The name comes from the reader's own runtime, so a server render and its
|
|
61
|
+
// hydration can legitimately disagree on the language it is written in. Same
|
|
62
|
+
// escape hatch, and same reason, as the relative timestamp.
|
|
63
|
+
suppressHydrationWarning
|
|
64
|
+
data-enigma-flag=""
|
|
65
|
+
data-flag-code={view.code}
|
|
66
|
+
data-flag-shape={view.shape}
|
|
67
|
+
{...rest}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
}
|
package/src/react/index.ts
CHANGED
|
@@ -10,16 +10,23 @@ export { useButton, type UseButtonResult } from "@/react/use-button";
|
|
|
10
10
|
export { Button, type ButtonProps } from "@/react/button";
|
|
11
11
|
export { setLinkComponent, getLinkComponent } from "@/react/link";
|
|
12
12
|
export { type ButtonOptions, type ButtonState } from "@/core/button";
|
|
13
|
+
export { Input } from "@/react/input";
|
|
13
14
|
export {
|
|
14
|
-
Input,
|
|
15
|
-
PasswordStrength,
|
|
16
15
|
type InputProps,
|
|
17
|
-
type
|
|
16
|
+
type InputBaseProps,
|
|
17
|
+
type InputType,
|
|
18
18
|
type FieldAction,
|
|
19
19
|
type BreachChecker,
|
|
20
20
|
type BreachState,
|
|
21
|
-
type BreachStatus
|
|
22
|
-
|
|
21
|
+
type BreachStatus,
|
|
22
|
+
type PasswordOnlyProps,
|
|
23
|
+
type SearchOnlyProps,
|
|
24
|
+
type PlainOnlyProps
|
|
25
|
+
} from "@/react/input/types";
|
|
26
|
+
// The meter as a standalone component, for a form that renders it somewhere else. Re-exported
|
|
27
|
+
// from the chunk rather than through `Input`, so importing only `Input` leaves it behind.
|
|
28
|
+
export { PasswordStrength, type PasswordStrengthProps } from "@/react/input/password";
|
|
29
|
+
export { Slot, mergeSlotProps, type SlotProps } from "@/react/slot";
|
|
23
30
|
export {
|
|
24
31
|
generatePassword,
|
|
25
32
|
estimatePasswordStrength,
|
|
@@ -33,3 +40,43 @@ export { Toaster, type ToasterProps, type ToastPosition, type ToastControls } fr
|
|
|
33
40
|
export { useNetworkState } from "@/react/use-network-state";
|
|
34
41
|
export { type NetworkState } from "@/core/network";
|
|
35
42
|
export { RelativeTime, type RelativeTimeProps } from "@/react/relative-time";
|
|
43
|
+
export { Flag, type FlagProps } from "@/react/flag";
|
|
44
|
+
export {
|
|
45
|
+
flagSrc,
|
|
46
|
+
flagView,
|
|
47
|
+
flagAttributes,
|
|
48
|
+
flagName,
|
|
49
|
+
normalizeFlagCode,
|
|
50
|
+
configureFlags,
|
|
51
|
+
type FlagOptions,
|
|
52
|
+
type FlagShape,
|
|
53
|
+
type FlagFormat,
|
|
54
|
+
type FlagSource
|
|
55
|
+
} from "@/core/flags";
|
|
56
|
+
export {
|
|
57
|
+
SearchPalette,
|
|
58
|
+
PaletteRoot,
|
|
59
|
+
PaletteTrigger,
|
|
60
|
+
PaletteContent,
|
|
61
|
+
PaletteField,
|
|
62
|
+
PaletteList,
|
|
63
|
+
PaletteItem,
|
|
64
|
+
PaletteFooter,
|
|
65
|
+
usePaletteContext,
|
|
66
|
+
type SearchPaletteProps,
|
|
67
|
+
type PaletteRootProps,
|
|
68
|
+
type PaletteListProps,
|
|
69
|
+
type PaletteSection,
|
|
70
|
+
type PaletteRow,
|
|
71
|
+
type PaletteContextValue
|
|
72
|
+
} from "@/react/palette";
|
|
73
|
+
export {
|
|
74
|
+
createRecentStore,
|
|
75
|
+
recentKey,
|
|
76
|
+
groupRows,
|
|
77
|
+
moveActive,
|
|
78
|
+
shortcutLabel,
|
|
79
|
+
isPaletteShortcut,
|
|
80
|
+
type RecentEntry,
|
|
81
|
+
type RecentStore
|
|
82
|
+
} from "@/core/palette";
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The built-in glyphs, drawn from the shared path data.
|
|
5
|
+
*
|
|
6
|
+
* Stroked with `currentColor` at 1em, so an icon inherits the field's text colour and size
|
|
7
|
+
* and needs no styling of its own. Replacing one is a `FieldAction` with the same `name`.
|
|
8
|
+
*/
|
|
9
|
+
const ICON_PROPS = {
|
|
10
|
+
viewBox: "0 0 24 24", width: "1em", height: "1em", fill: "none",
|
|
11
|
+
stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round",
|
|
12
|
+
"aria-hidden": true
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
export function Icon({ paths }: { paths: readonly string[]; }): ReactNode {
|
|
16
|
+
return <svg {...ICON_PROPS}>{paths.map((path) => <path key={path} d={path} />)}</svg>;
|
|
17
|
+
}
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Icon } from "@/react/input/icon";
|
|
4
|
+
import { INPUT_ICON_PATHS } from "@/core/input-icons";
|
|
5
|
+
import { writeValue } from "@/react/input/write-value";
|
|
6
|
+
import type { AnyInputProps, InputProps, FieldAction, BreachState } from "@/react/input/types";
|
|
7
|
+
import { forwardRef, lazy, Suspense, useCallback, useEffect, useId, useRef, useState, type ChangeEvent, type ReactNode } from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* `<Input>` - one field for every `type`, with the affordances each type needs and none of
|
|
11
|
+
* the styling.
|
|
12
|
+
*
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <Input type="email" required />
|
|
15
|
+
* <Input type="password" generate strength breach={checkPasswordBreach} />
|
|
16
|
+
* <Input type="search" items={docs} keys={["title"]} renderResults={...} />
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* ONE component keyed on `type`, because that is what HTML is: `type` is an attribute of a
|
|
20
|
+
* single element. What differs per type is which PROPS exist, and that is a discriminated
|
|
21
|
+
* union - `strength` on a text field is a compile error rather than a prop that silently
|
|
22
|
+
* does nothing. Widgets that are not one `<input>` stay their own components; the search
|
|
23
|
+
* PALETTE is a dialog, so it lives in `SearchPalette` rather than behind a prop here.
|
|
24
|
+
*
|
|
25
|
+
* WHAT LOADS. The field, its buttons and the reveal are this module and nothing else. The
|
|
26
|
+
* password estimator, the breach watcher and the search engine each live in their own chunk
|
|
27
|
+
* and are imported the moment the type that needs them is used - so a form of text and email
|
|
28
|
+
* fields ships none of them, and a page with one password field does not pay for search.
|
|
29
|
+
* The generator is loaded on the first press of its button, because until then it is a
|
|
30
|
+
* function nobody has called.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const PasswordExtras = lazy(() => import("@/react/input/password").then((module) => ({ default: module.PasswordExtras })));
|
|
34
|
+
const SearchExtras = lazy(() => import("@/react/input/search").then((module) => ({ default: module.SearchExtras })));
|
|
35
|
+
|
|
36
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(props, forwardedRef) {
|
|
37
|
+
const {
|
|
38
|
+
reveal,
|
|
39
|
+
revealLabels,
|
|
40
|
+
generate = false,
|
|
41
|
+
generateLabel = "Generate a password",
|
|
42
|
+
revealOnGenerate = true,
|
|
43
|
+
copyOnGenerate = false,
|
|
44
|
+
onGenerate,
|
|
45
|
+
actions = [],
|
|
46
|
+
position = "end",
|
|
47
|
+
strength = false,
|
|
48
|
+
onStrengthChange,
|
|
49
|
+
breach,
|
|
50
|
+
breachDelay = 500,
|
|
51
|
+
onBreachChange,
|
|
52
|
+
items,
|
|
53
|
+
keys,
|
|
54
|
+
delay,
|
|
55
|
+
limit,
|
|
56
|
+
fuse,
|
|
57
|
+
fuseOptions,
|
|
58
|
+
matcher,
|
|
59
|
+
onResults,
|
|
60
|
+
renderResults,
|
|
61
|
+
clearable,
|
|
62
|
+
clearLabel = "Clear",
|
|
63
|
+
wrapperProps,
|
|
64
|
+
fieldProps,
|
|
65
|
+
classNames,
|
|
66
|
+
children,
|
|
67
|
+
type = "text",
|
|
68
|
+
onChange,
|
|
69
|
+
...rest
|
|
70
|
+
} = props as AnyInputProps;
|
|
71
|
+
|
|
72
|
+
const innerRef = useRef<HTMLInputElement | null>(null);
|
|
73
|
+
const [element, setElement] = useState<HTMLInputElement | null>(null);
|
|
74
|
+
const [revealed, setRevealed] = useState(false);
|
|
75
|
+
const describedBy = useId();
|
|
76
|
+
|
|
77
|
+
const isPassword = type === "password";
|
|
78
|
+
const isSearch = type === "search";
|
|
79
|
+
const showReveal = reveal ?? isPassword;
|
|
80
|
+
const showGenerate = generate !== false && isPassword;
|
|
81
|
+
const showClear = (clearable ?? isSearch) && isSearch;
|
|
82
|
+
|
|
83
|
+
// Tracked whether the field is controlled or not, because the meter, the breach check
|
|
84
|
+
// and the clear button need the current value, and an uncontrolled field never reports
|
|
85
|
+
// it as a prop.
|
|
86
|
+
const controlled = rest.value !== undefined;
|
|
87
|
+
const [ownValue, setOwnValue] = useState(String(rest.defaultValue ?? ""));
|
|
88
|
+
const value = controlled ? String(rest.value ?? "") : ownValue;
|
|
89
|
+
|
|
90
|
+
const handleChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
|
91
|
+
if (!controlled) setOwnValue(event.target.value);
|
|
92
|
+
onChange?.(event);
|
|
93
|
+
}, [controlled, onChange]);
|
|
94
|
+
|
|
95
|
+
/* -------- the reveal, and the caret it would otherwise eat -------- */
|
|
96
|
+
|
|
97
|
+
// Captured BEFORE the type switch. Assigning `type` while an input is focused resets
|
|
98
|
+
// the caret to 0 in Chromium, and it does it one MACROTASK later - so anything read
|
|
99
|
+
// after the switch is already the clobbered value.
|
|
100
|
+
const caret = useRef<[number, number] | null>(null);
|
|
101
|
+
|
|
102
|
+
const toggleReveal = useCallback(() => {
|
|
103
|
+
const input = innerRef.current;
|
|
104
|
+
if (input && typeof document !== "undefined" && document.activeElement === input) {
|
|
105
|
+
try {
|
|
106
|
+
const { selectionStart, selectionEnd } = input;
|
|
107
|
+
caret.current = selectionStart === null || selectionEnd === null ? null : [selectionStart, selectionEnd];
|
|
108
|
+
} catch {
|
|
109
|
+
caret.current = null; // selection is not supported on every input type
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
setRevealed((current) => !current);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
const selection = caret.current;
|
|
117
|
+
if (!selection) return;
|
|
118
|
+
caret.current = null;
|
|
119
|
+
|
|
120
|
+
const restore = (): void => {
|
|
121
|
+
const input = innerRef.current;
|
|
122
|
+
if (!input || document.activeElement !== input) return;
|
|
123
|
+
try { input.setSelectionRange(selection[0], selection[1]); } catch { /* unsupported */ }
|
|
124
|
+
};
|
|
125
|
+
restore();
|
|
126
|
+
// And again on the next macrotask, which is where Chromium actually clobbers it.
|
|
127
|
+
// A restore that only runs inline silently loses. Focus is re-checked first, so a
|
|
128
|
+
// visitor who clicked elsewhere meanwhile is not dragged back.
|
|
129
|
+
const timer = setTimeout(restore, 0);
|
|
130
|
+
return () => clearTimeout(timer);
|
|
131
|
+
}, [revealed]);
|
|
132
|
+
|
|
133
|
+
/* -------- the generator, loaded when it is first pressed -------- */
|
|
134
|
+
|
|
135
|
+
const handleGenerate = useCallback(async () => {
|
|
136
|
+
const input = innerRef.current;
|
|
137
|
+
if (!input) return;
|
|
138
|
+
// Imported here rather than at the top: a form with a password field that nobody
|
|
139
|
+
// ever asks to generate has no reason to carry a CSPRNG alphabet around.
|
|
140
|
+
const { generatePassword } = await import("@/core/password");
|
|
141
|
+
const password = generatePassword(typeof generate === "object" ? generate : {});
|
|
142
|
+
writeValue(input, password);
|
|
143
|
+
if (revealOnGenerate) setRevealed(true);
|
|
144
|
+
if (copyOnGenerate) void navigator.clipboard?.writeText(password).catch(() => {
|
|
145
|
+
// Denied permission or an insecure context. The password is in the field, which
|
|
146
|
+
// is the part that matters, so there is nothing to report.
|
|
147
|
+
});
|
|
148
|
+
input.focus();
|
|
149
|
+
onGenerate?.(password);
|
|
150
|
+
}, [generate, revealOnGenerate, copyOnGenerate, onGenerate]);
|
|
151
|
+
|
|
152
|
+
const handleClear = useCallback(() => {
|
|
153
|
+
const input = innerRef.current;
|
|
154
|
+
if (!input) return;
|
|
155
|
+
writeValue(input, "");
|
|
156
|
+
input.focus();
|
|
157
|
+
}, []);
|
|
158
|
+
|
|
159
|
+
/* -------- what the per-type chunks report back -------- */
|
|
160
|
+
|
|
161
|
+
const [score, setScore] = useState<number | null>(null);
|
|
162
|
+
const [breached, setBreached] = useState(false);
|
|
163
|
+
|
|
164
|
+
const handleBreach = useCallback((state: BreachState) => {
|
|
165
|
+
setBreached(state.status === "breached");
|
|
166
|
+
onBreachChange?.(state);
|
|
167
|
+
}, [onBreachChange]);
|
|
168
|
+
|
|
169
|
+
/* -------- rendering -------- */
|
|
170
|
+
|
|
171
|
+
const builtIn: FieldAction[] = [];
|
|
172
|
+
if (showGenerate) {
|
|
173
|
+
builtIn.push({
|
|
174
|
+
name: "generate",
|
|
175
|
+
label: generateLabel,
|
|
176
|
+
icon: <Icon paths={INPUT_ICON_PATHS.generate} />,
|
|
177
|
+
onSelect: () => { void handleGenerate(); }
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
if (showReveal) {
|
|
181
|
+
builtIn.push({
|
|
182
|
+
name: "reveal",
|
|
183
|
+
label: revealed ? revealLabels?.hide ?? "Hide password" : revealLabels?.show ?? "Show password",
|
|
184
|
+
icon: <Icon paths={revealed ? INPUT_ICON_PATHS.eyeOff : INPUT_ICON_PATHS.eye} />,
|
|
185
|
+
pressed: revealed,
|
|
186
|
+
onSelect: toggleReveal
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
if (showClear) {
|
|
190
|
+
builtIn.push({
|
|
191
|
+
name: "clear",
|
|
192
|
+
label: clearLabel,
|
|
193
|
+
icon: <Icon paths={INPUT_ICON_PATHS.clear} />,
|
|
194
|
+
// Nothing to clear is nothing to press: the button would be a target that does
|
|
195
|
+
// not respond, which reads as broken rather than as unavailable.
|
|
196
|
+
visible: value.length > 0,
|
|
197
|
+
onSelect: handleClear
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const overridden = new Set(actions.map((action) => action.name));
|
|
202
|
+
const rendered = [...builtIn.filter((action) => !overridden.has(action.name)), ...actions]
|
|
203
|
+
.filter((action) => action.visible !== false);
|
|
204
|
+
|
|
205
|
+
const locked = rest.disabled === true || rest.readOnly === true;
|
|
206
|
+
const buttons = rendered.length === 0 ? null : (
|
|
207
|
+
<span data-enigma-input-actions="" data-position={position} className={classNames?.actions}>
|
|
208
|
+
{rendered.map((action) => (
|
|
209
|
+
<button
|
|
210
|
+
key={action.name}
|
|
211
|
+
type="button"
|
|
212
|
+
data-enigma-input-action={action.name}
|
|
213
|
+
className={classNames?.action}
|
|
214
|
+
aria-label={action.label}
|
|
215
|
+
title={action.label}
|
|
216
|
+
aria-pressed={action.pressed}
|
|
217
|
+
disabled={locked}
|
|
218
|
+
// Keeps focus in the field: the visitor is mid-word and expects to keep
|
|
219
|
+
// typing, and a caret that never left needs no restoring.
|
|
220
|
+
onMouseDown={(event) => event.preventDefault()}
|
|
221
|
+
onClick={action.onSelect}
|
|
222
|
+
>
|
|
223
|
+
{action.icon}
|
|
224
|
+
</button>
|
|
225
|
+
))}
|
|
226
|
+
</span>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
const wantsPasswordChunk = isPassword && (strength !== false || Boolean(breach));
|
|
230
|
+
const wantsSearchChunk = isSearch && Boolean(items || matcher);
|
|
231
|
+
|
|
232
|
+
return (
|
|
233
|
+
<div
|
|
234
|
+
{...wrapperProps}
|
|
235
|
+
data-enigma-input-root=""
|
|
236
|
+
data-revealed={revealed ? "" : undefined}
|
|
237
|
+
data-breached={breached ? "" : undefined}
|
|
238
|
+
data-score={score ?? undefined}
|
|
239
|
+
>
|
|
240
|
+
<div {...fieldProps} data-enigma-input-field="">
|
|
241
|
+
{position === "start" && buttons}
|
|
242
|
+
<input
|
|
243
|
+
{...rest}
|
|
244
|
+
ref={(node) => {
|
|
245
|
+
innerRef.current = node;
|
|
246
|
+
setElement(node);
|
|
247
|
+
if (typeof forwardedRef === "function") forwardedRef(node);
|
|
248
|
+
else if (forwardedRef) forwardedRef.current = node;
|
|
249
|
+
}}
|
|
250
|
+
// Revealing a password is a type switch, which is what the caret dance
|
|
251
|
+
// above exists for.
|
|
252
|
+
type={revealed && isPassword ? "text" : type}
|
|
253
|
+
onChange={handleChange}
|
|
254
|
+
data-enigma-input=""
|
|
255
|
+
aria-describedby={score !== null ? describedBy : rest["aria-describedby"]}
|
|
256
|
+
/>
|
|
257
|
+
{position === "end" && buttons}
|
|
258
|
+
</div>
|
|
259
|
+
{/* No fallback: the field is already on screen and usable, and a spinner where a
|
|
260
|
+
meter is about to appear moves the layout twice for nothing. */}
|
|
261
|
+
{wantsPasswordChunk && (
|
|
262
|
+
<Suspense fallback={null}>
|
|
263
|
+
<PasswordExtras
|
|
264
|
+
id={describedBy}
|
|
265
|
+
value={value}
|
|
266
|
+
strength={strength}
|
|
267
|
+
onStrengthChange={onStrengthChange}
|
|
268
|
+
onScore={setScore}
|
|
269
|
+
breach={breach}
|
|
270
|
+
breachDelay={breachDelay}
|
|
271
|
+
onBreachChange={handleBreach}
|
|
272
|
+
classNames={classNames?.strength}
|
|
273
|
+
/>
|
|
274
|
+
</Suspense>
|
|
275
|
+
)}
|
|
276
|
+
{wantsSearchChunk && (
|
|
277
|
+
<Suspense fallback={null}>
|
|
278
|
+
<SearchExtras
|
|
279
|
+
input={element}
|
|
280
|
+
items={items}
|
|
281
|
+
keys={keys}
|
|
282
|
+
delay={delay}
|
|
283
|
+
limit={limit}
|
|
284
|
+
fuse={fuse}
|
|
285
|
+
fuseOptions={fuseOptions}
|
|
286
|
+
matcher={matcher}
|
|
287
|
+
onResults={onResults}
|
|
288
|
+
renderResults={renderResults}
|
|
289
|
+
/>
|
|
290
|
+
</Suspense>
|
|
291
|
+
)}
|
|
292
|
+
{children}
|
|
293
|
+
</div>
|
|
294
|
+
);
|
|
295
|
+
}) as <Item = unknown>(props: InputProps<Item> & { ref?: React.Ref<HTMLInputElement>; }) => ReactNode;
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Types only. A VALUE re-export here (`PasswordStrength`, say) would be a static edge into
|
|
299
|
+
* the password chunk, and a static edge is exactly what stops it being a chunk: the bundler
|
|
300
|
+
* would fold it back into whatever imports `Input`. The entry re-exports it instead, where
|
|
301
|
+
* an unused export is dropped.
|
|
302
|
+
*/
|
|
303
|
+
export type {
|
|
304
|
+
InputProps, InputBaseProps, InputType, FieldAction,
|
|
305
|
+
BreachChecker, BreachState, BreachStatus,
|
|
306
|
+
PasswordOnlyProps, SearchOnlyProps, PlainOnlyProps
|
|
307
|
+
} from "@/react/input/types";
|