@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.
Files changed (89) hide show
  1. package/dist/button-CaXaqG_K.d.ts +63 -0
  2. package/dist/chunk-2QFTRNAZ.js +88 -0
  3. package/dist/chunk-6BGBYUSZ.js +114 -0
  4. package/dist/chunk-AU3H5WIY.js +107 -0
  5. package/dist/chunk-F25CGNQC.js +19 -0
  6. package/dist/chunk-HC2ME5PU.js +168 -0
  7. package/dist/chunk-HS3X3XCW.js +43 -0
  8. package/dist/chunk-IIT6U7LM.js +249 -0
  9. package/dist/chunk-IXVMRVD4.js +96 -0
  10. package/dist/chunk-KJINGUQN.js +188 -0
  11. package/dist/chunk-LKA2UG4P.js +542 -0
  12. package/dist/chunk-MMQPZGSU.js +161 -0
  13. package/dist/chunk-OCMI7R6H.js +79 -0
  14. package/dist/chunk-QYMUIW5I.js +28 -0
  15. package/dist/chunk-S653GLSF.js +17 -0
  16. package/dist/chunk-SNYUBXWQ.js +149 -0
  17. package/dist/chunk-U3V4EHOB.js +41 -0
  18. package/dist/chunk-UOSSNUSC.js +309 -0
  19. package/dist/chunk-WSB655JB.js +47 -0
  20. package/dist/chunk-XNNQRA35.js +31 -0
  21. package/dist/chunk-XQHCZAPJ.js +102 -0
  22. package/dist/chunk-ZCUFYBPB.js +154 -0
  23. package/dist/chunk-ZWR2EXHQ.js +55 -0
  24. package/dist/flags-BBJc9unY.d.ts +133 -0
  25. package/dist/index-D-ARvWpQ.d.ts +306 -0
  26. package/dist/index-DNXPtY9X.d.ts +144 -0
  27. package/dist/index.d.ts +16 -496
  28. package/dist/index.js +9 -1
  29. package/dist/input-BwXjFenq.d.ts +77 -0
  30. package/dist/marquee-CJ3Uwy3E.d.ts +81 -0
  31. package/dist/network-D2LsBG_k.d.ts +39 -0
  32. package/dist/next/index.d.ts +20 -3
  33. package/dist/next/index.js +23 -3
  34. package/dist/notifications-BpVV6sel.d.ts +70 -0
  35. package/dist/password-3DRQYAYQ.js +2 -0
  36. package/dist/password-C8lG4Zm9.d.ts +71 -0
  37. package/dist/password-FB2CUEKJ.js +1 -0
  38. package/dist/react/button.d.ts +75 -0
  39. package/dist/react/button.js +4 -0
  40. package/dist/react/flag.d.ts +37 -0
  41. package/dist/react/flag.js +3 -0
  42. package/dist/react/index.d.ts +28 -286
  43. package/dist/react/index.js +22 -2
  44. package/dist/react/input.d.ts +4 -0
  45. package/dist/react/input.js +3 -0
  46. package/dist/react/marquee.d.ts +44 -0
  47. package/dist/react/marquee.js +3 -0
  48. package/dist/react/network.d.ts +20 -0
  49. package/dist/react/network.js +3 -0
  50. package/dist/react/notifications.d.ts +17 -0
  51. package/dist/react/notifications.js +3 -0
  52. package/dist/react/palette.d.ts +3 -0
  53. package/dist/react/palette.js +4 -0
  54. package/dist/react/relative-time.d.ts +21 -0
  55. package/dist/react/relative-time.js +3 -0
  56. package/dist/react/search.d.ts +30 -0
  57. package/dist/react/search.js +3 -0
  58. package/dist/react/slot.d.ts +47 -0
  59. package/dist/react/slot.js +2 -0
  60. package/dist/react/toast.d.ts +40 -0
  61. package/dist/react/toast.js +4 -0
  62. package/dist/react-router/index.d.ts +20 -3
  63. package/dist/react-router/index.js +23 -3
  64. package/dist/relative-time-YpRTG7YH.d.ts +106 -0
  65. package/dist/search-P4OHCRXD.js +50 -0
  66. package/package.json +53 -1
  67. package/registry.json +235 -16
  68. package/src/core/flags.ts +266 -0
  69. package/src/core/input-icons.ts +34 -0
  70. package/src/core/input.ts +5 -21
  71. package/src/core/palette.ts +0 -0
  72. package/src/index.ts +18 -0
  73. package/src/react/button.tsx +70 -4
  74. package/src/react/flag.tsx +70 -0
  75. package/src/react/index.ts +52 -5
  76. package/src/react/input/icon.tsx +17 -0
  77. package/src/react/input/index.tsx +307 -0
  78. package/src/react/input/password.tsx +174 -0
  79. package/src/react/input/search.tsx +82 -0
  80. package/src/react/input/types.ts +146 -0
  81. package/src/react/input/write-value.ts +18 -0
  82. package/src/react/palette/context.ts +60 -0
  83. package/src/react/palette/index.tsx +66 -0
  84. package/src/react/palette/root.tsx +621 -0
  85. package/src/react/slot.tsx +91 -0
  86. package/src/react/use-button.ts +3 -1
  87. package/dist/chunk-53DQY6LP.js +0 -1164
  88. package/dist/chunk-U2KYYMBJ.js +0 -724
  89. package/src/react/input.tsx +0 -429
@@ -0,0 +1,174 @@
1
+ "use client";
2
+
3
+ import type { BreachChecker, BreachState, PasswordStrengthClassNames } from "@/react/input/types";
4
+ import { useEffect, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
5
+ import { estimatePasswordStrength, type EstimateOptions, type PasswordStrengthReport, type PasswordScore } from "@/core/password";
6
+
7
+ /**
8
+ * Everything only a password field needs: the estimator, the meter and the breach watcher.
9
+ *
10
+ * Its own chunk. `<Input>` imports it dynamically the moment a password field asks for
11
+ * `strength` or `breach`, so a form of text and email fields never downloads a word list, an
12
+ * entropy table or an abort controller it has no use for. Nothing here is imported by the
13
+ * field statically - that would fold the chunk straight back into it.
14
+ */
15
+
16
+ const SCORE_LABELS = ["Very weak", "Weak", "Fair", "Strong", "Very strong"] as const;
17
+
18
+ export interface PasswordExtrasProps {
19
+ /** Id the field points `aria-describedby` at. */
20
+ id: string;
21
+ value: string;
22
+ strength: boolean | EstimateOptions;
23
+ onStrengthChange?: (report: PasswordStrengthReport) => void;
24
+ /** Reports the score up so the ROOT can carry `data-score` for a theme to style. */
25
+ onScore: (score: number | null) => void;
26
+ breach?: BreachChecker;
27
+ breachDelay: number;
28
+ onBreachChange: (state: BreachState) => void;
29
+ classNames?: PasswordStrengthClassNames;
30
+ }
31
+
32
+ export function PasswordExtras({
33
+ id,
34
+ value,
35
+ strength,
36
+ onStrengthChange,
37
+ onScore,
38
+ breach,
39
+ breachDelay,
40
+ onBreachChange,
41
+ classNames
42
+ }: PasswordExtrasProps): ReactNode {
43
+ const estimateOptions = typeof strength === "object" ? strength : undefined;
44
+ const userInputs = estimateOptions?.userInputs;
45
+ const report = useMemo(
46
+ () => (strength === false ? null : estimatePasswordStrength(value, { userInputs })),
47
+ // The array is compared by identity, so a literal would re-estimate every render.
48
+ [strength, value, userInputs]
49
+ );
50
+
51
+ const strengthListener = useRef(onStrengthChange);
52
+ strengthListener.current = onStrengthChange;
53
+ const scoreListener = useRef(onScore);
54
+ scoreListener.current = onScore;
55
+
56
+ useEffect(() => {
57
+ if (!report) return;
58
+ strengthListener.current?.(report);
59
+ scoreListener.current(report.empty ? null : report.score);
60
+ }, [report]);
61
+
62
+ /* -------- the breach check -------- */
63
+
64
+ const [breachState, setBreachState] = useState<BreachState>({ status: "idle", count: 0, error: null });
65
+ const breachListener = useRef(onBreachChange);
66
+ breachListener.current = onBreachChange;
67
+
68
+ useEffect(() => {
69
+ if (!breach || !value) {
70
+ setBreachState({ status: "idle", count: 0, error: null });
71
+ return;
72
+ }
73
+ const controller = new AbortController();
74
+ const timer = setTimeout(() => {
75
+ setBreachState({ status: "checking", count: 0, error: null });
76
+ breach(value, { signal: controller.signal })
77
+ .then((result) => {
78
+ if (controller.signal.aborted) return;
79
+ setBreachState({ status: result.breached ? "breached" : "safe", count: result.count, error: null });
80
+ })
81
+ .catch((error: unknown) => {
82
+ // An abort is this effect cleaning up after itself, not a failure.
83
+ if (controller.signal.aborted) return;
84
+ setBreachState({ status: "error", count: 0, error });
85
+ });
86
+ }, breachDelay);
87
+
88
+ return () => {
89
+ clearTimeout(timer);
90
+ // Cancels the request in flight, so the answer to a password that is three
91
+ // keystrokes old can never overwrite the answer to the current one.
92
+ controller.abort();
93
+ };
94
+ }, [breach, value, breachDelay]);
95
+
96
+ useEffect(() => {
97
+ breachListener.current(breachState);
98
+ }, [breachState]);
99
+
100
+ if (!report) return null;
101
+ return <PasswordStrength id={id} report={report} classNames={classNames} />;
102
+ }
103
+
104
+ export interface PasswordStrengthProps extends Omit<ComponentPropsWithoutRef<"div">, "children"> {
105
+ /** The password to score. Ignored when `report` is given. */
106
+ value?: string;
107
+ /** A report you already have, e.g. from `<Input onStrengthChange>`. */
108
+ report?: PasswordStrengthReport;
109
+ userInputs?: string[];
110
+ /** Bars to draw. Five, so each score has one of its own. */
111
+ segments?: number;
112
+ /** Your own wording, worst first. */
113
+ labels?: readonly string[];
114
+ /** Show the top warning under the bars. On by default; it is the useful half. */
115
+ showWarning?: boolean;
116
+ /**
117
+ * Classes for the inner parts. The score is on the ROOT, so a segment colours itself
118
+ * with a group variant - `group-data-[score=0]/strength:bg-red-600` and so on.
119
+ */
120
+ classNames?: PasswordStrengthClassNames;
121
+ }
122
+
123
+ /**
124
+ * The bars under a password field.
125
+ *
126
+ * Structure and state only - `data-score` on the root and `data-filled` per segment are
127
+ * where the colours attach. The component picks no colours, because red-through-green is a
128
+ * palette decision and this package does not own one.
129
+ */
130
+ export function PasswordStrength({
131
+ value = "",
132
+ report,
133
+ userInputs,
134
+ segments = 5,
135
+ labels = SCORE_LABELS,
136
+ showWarning = true,
137
+ classNames,
138
+ ...props
139
+ }: PasswordStrengthProps): ReactNode {
140
+ const computed = useMemo(
141
+ () => report ?? estimatePasswordStrength(value, { userInputs }),
142
+ [report, value, userInputs]
143
+ );
144
+
145
+ const score: PasswordScore = computed.score;
146
+ return (
147
+ <div
148
+ {...props}
149
+ data-enigma-password-strength=""
150
+ data-score={computed.empty ? undefined : score}
151
+ data-empty={computed.empty ? "" : undefined}
152
+ >
153
+ <div data-enigma-password-strength-track="" aria-hidden="true" className={classNames?.track}>
154
+ {Array.from({ length: segments }, (unused, index) => (
155
+ <span
156
+ key={index}
157
+ data-enigma-password-strength-segment=""
158
+ className={classNames?.segment}
159
+ // Score 0 still fills one bar: an empty track next to a filled
160
+ // field reads as "not measured", not as "this is a bad password".
161
+ data-filled={!computed.empty && index <= score ? "" : undefined}
162
+ />
163
+ ))}
164
+ </div>
165
+ {/* Announced when the band changes, which is rarely enough not to chatter. */}
166
+ <p data-enigma-password-strength-label="" role="status" aria-live="polite" className={classNames?.label}>
167
+ {computed.empty ? "" : labels[score] ?? ""}
168
+ </p>
169
+ {showWarning && !computed.empty && computed.warnings.length > 0 && (
170
+ <p data-enigma-password-strength-warning="" className={classNames?.warning}>{computed.warnings[0]}</p>
171
+ )}
172
+ </div>
173
+ );
174
+ }
@@ -0,0 +1,82 @@
1
+ "use client";
2
+
3
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
4
+ import { createSearch, type SearchInstance, type SearchMatch, type SearchOptions } from "@/core/search";
5
+
6
+ /**
7
+ * The search wiring for `<Input type="search">`: debouncing, ranking, cancellation, and the
8
+ * results the field reports.
9
+ *
10
+ * Its own chunk, imported the moment a search field is given something to search - so a
11
+ * password form never downloads a matcher, and a project that passes Fuse's constructor
12
+ * only pays for it on the page that has the field.
13
+ *
14
+ * The engine is attached to the field the base component rendered rather than rendering one
15
+ * of its own: two owners of one input is the thing that makes a wrapper feel wrong, and the
16
+ * field has to stay a plain `<input>` the consumer can pass any native prop to.
17
+ */
18
+
19
+ export interface SearchExtrasProps<Item = unknown> {
20
+ /** The field itself. Null until it mounts, which is one render. */
21
+ input: HTMLInputElement | null;
22
+ items?: Item[];
23
+ keys?: SearchOptions<Item>["keys"];
24
+ delay?: number;
25
+ limit?: number;
26
+ fuse?: SearchOptions<Item>["fuse"];
27
+ fuseOptions?: SearchOptions<Item>["fuseOptions"];
28
+ matcher?: SearchOptions<Item>["matcher"];
29
+ onResults?: (matches: SearchMatch<Item>[], query: string) => void;
30
+ renderResults?: (matches: SearchMatch<Item>[], query: string) => ReactNode;
31
+ }
32
+
33
+ export function SearchExtras<Item>({
34
+ input,
35
+ items,
36
+ keys,
37
+ delay = 150,
38
+ limit,
39
+ fuse,
40
+ fuseOptions,
41
+ matcher,
42
+ onResults,
43
+ renderResults
44
+ }: SearchExtrasProps<Item>): ReactNode {
45
+ const [results, setResults] = useState<SearchMatch<Item>[]>([]);
46
+ const [query, setQuery] = useState("");
47
+
48
+ const listener = useRef(onResults);
49
+ listener.current = onResults;
50
+
51
+ // Built once. The engine indexes on construction, so rebuilding it per render would
52
+ // re-index the whole list on every keystroke - the defect this exists to avoid.
53
+ const instance = useMemo<SearchInstance<Item>>(() => createSearch<Item>({
54
+ items,
55
+ keys,
56
+ fuse,
57
+ fuseOptions,
58
+ matcher,
59
+ debounce: delay,
60
+ limit,
61
+ onResults: (next, nextQuery) => {
62
+ setResults(next);
63
+ setQuery(nextQuery);
64
+ listener.current?.(next, nextQuery);
65
+ }
66
+ // eslint-disable-next-line react-hooks/exhaustive-deps
67
+ }), []);
68
+
69
+ useEffect(() => () => instance.destroy(), [instance]);
70
+
71
+ // New data must re-index and re-run the visible query, or the list keeps showing
72
+ // matches against items that are gone.
73
+ useEffect(() => { instance.setItems(items ?? []); }, [instance, items]);
74
+ useEffect(() => { instance.update({ keys, fuse, fuseOptions, matcher, debounce: delay, limit }); }, [instance, keys, fuse, fuseOptions, matcher, delay, limit]);
75
+
76
+ useEffect(() => {
77
+ if (!input) return;
78
+ return instance.attach(input);
79
+ }, [instance, input]);
80
+
81
+ return renderResults ? <>{renderResults(results, query)}</> : null;
82
+ }
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The prop shapes for `<Input>`, split from the component so the per-type chunks can import
3
+ * them without importing the component - and so every type in here is erased at build,
4
+ * which is what keeps a `type="text"` field from pulling the password estimator or a search
5
+ * engine into the bundle.
6
+ *
7
+ * The API is ONE component keyed on `type`, the way HTML itself is: `type` is an attribute
8
+ * of one element, not a different element. Radix splits Select, Checkbox and Slider into
9
+ * their own primitives because those are composed widgets rather than an `<input>` - that
10
+ * split is about the DOM, not about the props. What varies here is which props EXIST, and
11
+ * that is a discriminated union: `strength` on a text field and `items` on a password field
12
+ * are compile errors rather than props that quietly do nothing.
13
+ */
14
+
15
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
16
+ import type { SearchMatch, FuseConstructor, SearchOptions } from "@/core/search";
17
+ import type { GeneratePasswordOptions, EstimateOptions, PasswordStrengthReport } from "@/core/password";
18
+
19
+ /** An extra button inside the field. The built-ins are the same shape. */
20
+ export interface FieldAction {
21
+ /** Stable id. Lands on `data-enigma-input-action`, and replaces a built-in of the same name. */
22
+ name: string;
23
+ /** Accessible name. Becomes `aria-label` and `title`. */
24
+ label: string;
25
+ icon: ReactNode;
26
+ onSelect: () => void;
27
+ /** Renders `aria-pressed`. Omit for actions that are not toggles. */
28
+ pressed?: boolean;
29
+ /** Default true. A false action is not rendered at all. */
30
+ visible?: boolean;
31
+ }
32
+
33
+ export type BreachStatus = "idle" | "checking" | "safe" | "breached" | "error";
34
+
35
+ export interface BreachState {
36
+ status: BreachStatus;
37
+ /** How many breaches the password appears in. 0 unless `status` is "breached". */
38
+ count: number;
39
+ /** Whatever the checker threw. The form decides whether that is worth showing. */
40
+ error: unknown;
41
+ }
42
+
43
+ export type BreachChecker = (password: string, options: { signal: AbortSignal; }) => Promise<{ breached: boolean; count: number; }>;
44
+
45
+ /** Every `type` a single `<input>` element takes, plus the two this component teaches. */
46
+ export type InputType =
47
+ | "text" | "email" | "password" | "search" | "tel" | "url" | "number"
48
+ | "date" | "datetime-local" | "month" | "week" | "time"
49
+ | "color" | "file" | "range" | "hidden" | "checkbox" | "radio";
50
+
51
+ export interface PasswordStrengthClassNames {
52
+ track?: string;
53
+ segment?: string;
54
+ label?: string;
55
+ warning?: string;
56
+ }
57
+
58
+ /** What every type has, whatever it is. */
59
+ export interface InputBaseProps extends Omit<ComponentPropsWithoutRef<"input">, "children" | "type"> {
60
+ /** The reveal toggle. Defaults to on for `type="password"` and off for everything else. */
61
+ reveal?: boolean;
62
+ revealLabels?: { show?: string; hide?: string; };
63
+ /** Extra buttons, or a replacement for a built-in by name. */
64
+ actions?: FieldAction[];
65
+ /** Which end the buttons sit at. Position them yourself; this only orders the markup. */
66
+ position?: "start" | "end";
67
+ /** Props for the element wrapping the field, its buttons and anything under them. */
68
+ wrapperProps?: ComponentPropsWithoutRef<"div">;
69
+ /** Props for the row holding the field and its buttons - this is what you position. */
70
+ fieldProps?: ComponentPropsWithoutRef<"div">;
71
+ /**
72
+ * Classes for the parts you cannot reach with a ref, which is what Tailwind needs.
73
+ * `className` still goes to the `<input>` itself, where you would expect it.
74
+ */
75
+ classNames?: {
76
+ actions?: string;
77
+ action?: string;
78
+ strength?: PasswordStrengthClassNames;
79
+ results?: string;
80
+ result?: string;
81
+ };
82
+ /** Rendered inside the wrapper, after everything the type adds. Your error goes here. */
83
+ children?: ReactNode;
84
+ }
85
+
86
+ /** The half only a password field has. */
87
+ export interface PasswordOnlyProps {
88
+ type: "password";
89
+ /** Offer to generate a password. `true` for the defaults, or the generator's options. */
90
+ generate?: boolean | GeneratePasswordOptions;
91
+ generateLabel?: string;
92
+ /** Show what was generated. On by default - a password nobody can read is not usable. */
93
+ revealOnGenerate?: boolean;
94
+ /**
95
+ * Also copy it to the clipboard. OFF by default and worth leaving off: the clipboard is
96
+ * shared with every other app on the machine and is not cleared.
97
+ */
98
+ copyOnGenerate?: boolean;
99
+ onGenerate?: (password: string) => void;
100
+ /** Score the password as it is typed, and render the meter under the field. */
101
+ strength?: boolean | EstimateOptions;
102
+ onStrengthChange?: (report: PasswordStrengthReport) => void;
103
+ /**
104
+ * Check the password against a breach corpus - pass `checkPasswordBreach` from
105
+ * @enigmax/utils, or your own. It is a prop rather than a built-in because it makes a
106
+ * network request, and that is not a decision a field should take on its own.
107
+ */
108
+ breach?: BreachChecker;
109
+ /** Quiet time before a check fires, in ms. Default 500. */
110
+ breachDelay?: number;
111
+ onBreachChange?: (state: BreachState) => void;
112
+ }
113
+
114
+ /** The half only a search field has. The palette is its own component, not a prop. */
115
+ export interface SearchOnlyProps<Item = unknown> {
116
+ type: "search";
117
+ /** What to search. Leave it out for a field that only reports the query. */
118
+ items?: Item[];
119
+ /** Which fields to read. `["title", "body"]`, or leave it out for every string field. */
120
+ keys?: SearchOptions<Item>["keys"];
121
+ /** Quiet time before a search runs, in ms. Default 150. */
122
+ delay?: number;
123
+ /** Cap the result list. Applies to the empty query too. */
124
+ limit?: number;
125
+ /** Fuse.js's constructor, for fuzzy matching. Without it, a substring matcher is used. */
126
+ fuse?: FuseConstructor;
127
+ fuseOptions?: SearchOptions<Item>["fuseOptions"];
128
+ /** Replaces the engine outright, and wins over `fuse`. */
129
+ matcher?: SearchOptions<Item>["matcher"];
130
+ onResults?: (matches: SearchMatch<Item>[], query: string) => void;
131
+ /** Render the results under the field. Without it, the field only reports them. */
132
+ renderResults?: (matches: SearchMatch<Item>[], query: string) => ReactNode;
133
+ /** Clear button. On by default for a search field, because the platform's own is not. */
134
+ clearable?: boolean;
135
+ clearLabel?: string;
136
+ }
137
+
138
+ /** Everything else: one `<input>`, its native props, and no extras to bundle. */
139
+ export interface PlainOnlyProps {
140
+ type?: Exclude<InputType, "password" | "search">;
141
+ }
142
+
143
+ export type InputProps<Item = unknown> = InputBaseProps & (PasswordOnlyProps | SearchOnlyProps<Item> | PlainOnlyProps);
144
+
145
+ /** Everything the implementation reads, after the union has done its job at the call site. */
146
+ export type AnyInputProps<Item = unknown> = InputBaseProps & Partial<Omit<PasswordOnlyProps, "type">> & Partial<Omit<SearchOnlyProps<Item>, "type">> & { type?: InputType; };
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Write a value the way a keystroke would.
3
+ *
4
+ * Assigning `input.value` directly is invisible to React: it tracks the last value it
5
+ * rendered and skips the change event when the DOM disagrees with it. Going through the
6
+ * prototype's setter and dispatching a bubbling `input` event makes a generated password or
7
+ * a cleared search behave exactly like typing - which is what makes it work with a
8
+ * controlled field, an uncontrolled one, and a form library, without knowing which it is in.
9
+ *
10
+ * Its own module because both the field and the per-type chunks write values, and a helper
11
+ * imported from the field would tie a chunk back to the module that lazily loads it.
12
+ */
13
+ export function writeValue(input: HTMLInputElement, next: string): void {
14
+ const setter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, "value")?.set;
15
+ if (setter) setter.call(input, next);
16
+ else input.value = next;
17
+ input.dispatchEvent(new Event("input", { bubbles: true }));
18
+ }
@@ -0,0 +1,60 @@
1
+ "use client";
2
+
3
+ import type { SearchMatch } from "@/core/search";
4
+ import { createContext, useContext } from "react";
5
+ import type { RecentEntry } from "@/core/palette";
6
+
7
+ /** One line in the palette, whatever it came from. */
8
+ export interface PaletteRow<Item> {
9
+ /** Stable within a render. Used for React keys and for `aria-activedescendant`. */
10
+ id: string;
11
+ /** Which group it appears under. */
12
+ group: string;
13
+ /** `item` for a result, `recent` for a memory, `action` for a row the app declared. */
14
+ kind: "item" | "recent" | "action";
15
+ item?: Item;
16
+ recent?: RecentEntry;
17
+ /** Set for a result that came from the engine, so a renderer can show the score. */
18
+ match?: SearchMatch<Item>;
19
+ /** What running this row does. The palette closes afterwards unless this returns false. */
20
+ onSelect?: () => void | boolean;
21
+ label: string;
22
+ description?: string;
23
+ }
24
+
25
+ export interface PaletteContextValue<Item = unknown> {
26
+ open: boolean;
27
+ setOpen: (open: boolean) => void;
28
+ query: string;
29
+ setQuery: (query: string) => void;
30
+ rows: PaletteRow<Item>[];
31
+ active: number;
32
+ setActive: (index: number) => void;
33
+ /** Run a row: its own action, or the palette's `onSelect`, then remember it. */
34
+ select: (row: PaletteRow<Item> | undefined) => void;
35
+ /** Wipe the remembered searches. */
36
+ clearRecents: () => void;
37
+ forgetRecent: (entry: RecentEntry) => void;
38
+ /** True while the engine is loading or an async source is in flight. */
39
+ busy: boolean;
40
+ /** ids the parts need to point at each other. */
41
+ ids: { field: string; list: string; title: string; };
42
+ /** `Ctrl K` or `⌘ K`, whichever this platform uses. */
43
+ shortcutLabel: string;
44
+ triggerRef: { current: HTMLElement | null; };
45
+ fieldRef: { current: HTMLInputElement | null; };
46
+ rowId: (row: PaletteRow<Item>) => string;
47
+ }
48
+
49
+ /**
50
+ * Null rather than a default value: a part rendered outside its Root is a mistake with a
51
+ * clear fix, and a silent default would leave it half-working - a field that types into
52
+ * nothing, an item that never highlights.
53
+ */
54
+ export const PaletteContext = createContext<PaletteContextValue<never> | null>(null);
55
+
56
+ export function usePaletteContext<Item = unknown>(part: string): PaletteContextValue<Item> {
57
+ const value = useContext(PaletteContext);
58
+ if (!value) throw new Error(`<${part}> must be rendered inside <SearchPalette.Root>.`);
59
+ return value as unknown as PaletteContextValue<Item>;
60
+ }
@@ -0,0 +1,66 @@
1
+ "use client";
2
+
3
+ import type { ReactNode } from "react";
4
+ import type { PaletteRow } from "@/react/palette/context";
5
+ import {
6
+ PaletteRoot, PaletteTrigger, PaletteContent, PaletteField, PaletteList, PaletteItem, PaletteFooter,
7
+ type PaletteRootProps, type PaletteListProps
8
+ } from "@/react/palette/root";
9
+
10
+ export interface SearchPaletteProps<Item> extends PaletteRootProps<Item> {
11
+ placeholder?: string;
12
+ /** The trigger's own content. Pass null for a palette that only opens on the shortcut. */
13
+ trigger?: ReactNode | null;
14
+ /** Render one row, same signature as `SearchPalette.List`. */
15
+ renderItem?: PaletteListProps<Item>["children"];
16
+ empty?: ReactNode;
17
+ footer?: ReactNode;
18
+ }
19
+
20
+ /**
21
+ * The palette, assembled.
22
+ *
23
+ * ```tsx
24
+ * <SearchPalette items={docs} keys={["title", "body"]} onSelect={(doc) => go(doc.href)} />
25
+ * ```
26
+ *
27
+ * Everything it renders is a part you can render yourself instead - see `SearchPalette.Root`
28
+ * for the anatomy. This is the composition that covers the common case, not a different
29
+ * component: it is those parts, in the order they belong in.
30
+ */
31
+ export function SearchPalette<Item>({
32
+ placeholder = "Search",
33
+ trigger,
34
+ renderItem,
35
+ empty,
36
+ footer,
37
+ children,
38
+ ...root
39
+ }: SearchPaletteProps<Item>): ReactNode {
40
+ return (
41
+ <PaletteRoot<Item> {...root}>
42
+ {trigger !== null && <PaletteTrigger>{trigger}</PaletteTrigger>}
43
+ <PaletteContent>
44
+ <PaletteField placeholder={placeholder} aria-label={placeholder} />
45
+ <PaletteList<Item> empty={empty}>{renderItem}</PaletteList>
46
+ {footer === null ? null : <PaletteFooter>{footer}</PaletteFooter>}
47
+ </PaletteContent>
48
+ {children}
49
+ </PaletteRoot>
50
+ );
51
+ }
52
+
53
+ SearchPalette.Root = PaletteRoot;
54
+ SearchPalette.Trigger = PaletteTrigger;
55
+ SearchPalette.Content = PaletteContent;
56
+ SearchPalette.Field = PaletteField;
57
+ SearchPalette.List = PaletteList;
58
+ SearchPalette.Item = PaletteItem;
59
+ SearchPalette.Footer = PaletteFooter;
60
+
61
+ export {
62
+ PaletteRoot, PaletteTrigger, PaletteContent, PaletteField, PaletteList, PaletteItem, PaletteFooter,
63
+ type PaletteRootProps, type PaletteListProps, type PaletteSection
64
+ } from "@/react/palette/root";
65
+ export { usePaletteContext, type PaletteRow, type PaletteContextValue } from "@/react/palette/context";
66
+ export type { PaletteRow as SearchPaletteRow };