@cosxai/ui 0.1.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 (109) hide show
  1. package/package.json +38 -0
  2. package/src/actionbar/ActionBar.tsx +436 -0
  3. package/src/actionbar/ActionBarButton.tsx +110 -0
  4. package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
  5. package/src/actionbar/ActionBarProvider.tsx +76 -0
  6. package/src/actionbar/actionbar-context.ts +23 -0
  7. package/src/actionbar/index.ts +13 -0
  8. package/src/actionbar/types.ts +50 -0
  9. package/src/actionbar/useActionBarItems.ts +47 -0
  10. package/src/ambient/AmbientBackdrop.tsx +74 -0
  11. package/src/ambient/CommandInput.tsx +107 -0
  12. package/src/ambient/SuperbarStrip.tsx +36 -0
  13. package/src/ambient/index.ts +6 -0
  14. package/src/bento/BentoCell.tsx +66 -0
  15. package/src/bento/BentoGrid.tsx +42 -0
  16. package/src/bento/index.ts +2 -0
  17. package/src/command/CommandPalette.tsx +277 -0
  18. package/src/command/CommandProvider.tsx +57 -0
  19. package/src/command/command-context.ts +12 -0
  20. package/src/command/index.ts +6 -0
  21. package/src/command/rank.ts +45 -0
  22. package/src/command/types.ts +26 -0
  23. package/src/command/useCommandSource.ts +37 -0
  24. package/src/dialogs/DialogsProvider.tsx +216 -0
  25. package/src/dialogs/Modal.tsx +204 -0
  26. package/src/dialogs/Toast.tsx +85 -0
  27. package/src/dialogs/dialogs-context.ts +6 -0
  28. package/src/dialogs/index.ts +10 -0
  29. package/src/dialogs/types.ts +37 -0
  30. package/src/dialogs/useDialogs.ts +8 -0
  31. package/src/editorial/EditorialSpotlight.tsx +63 -0
  32. package/src/editorial/Folio.tsx +52 -0
  33. package/src/editorial/PlateMarker.tsx +33 -0
  34. package/src/editorial/RomanSection.tsx +65 -0
  35. package/src/editorial/RunningMarginalia.tsx +65 -0
  36. package/src/editorial/index.ts +10 -0
  37. package/src/frutiger/GlossyOrb.tsx +79 -0
  38. package/src/frutiger/SkyBackdrop.tsx +114 -0
  39. package/src/frutiger/index.ts +2 -0
  40. package/src/hooks/index.ts +5 -0
  41. package/src/hooks/useKeyboardHotkey.ts +80 -0
  42. package/src/hooks/useReducedMotion.ts +20 -0
  43. package/src/hooks/useViewport.ts +61 -0
  44. package/src/index.ts +26 -0
  45. package/src/layout/Breadcrumb.tsx +74 -0
  46. package/src/layout/LeftNavRail.tsx +126 -0
  47. package/src/layout/MobileTabBar.tsx +101 -0
  48. package/src/layout/NavItem.tsx +128 -0
  49. package/src/layout/NavSearchTrigger.tsx +88 -0
  50. package/src/layout/NavSection.tsx +40 -0
  51. package/src/layout/RightSidebarPanel.tsx +111 -0
  52. package/src/layout/Shell.tsx +91 -0
  53. package/src/layout/StickyBanner.tsx +83 -0
  54. package/src/layout/Topbar.tsx +68 -0
  55. package/src/layout/index.ts +22 -0
  56. package/src/layout/useNavRailState.ts +69 -0
  57. package/src/lib/cn.ts +7 -0
  58. package/src/lib/time-utils.ts +44 -0
  59. package/src/neobrutalism/Marquee.tsx +81 -0
  60. package/src/neobrutalism/Sticker.tsx +71 -0
  61. package/src/neobrutalism/index.ts +4 -0
  62. package/src/primitives/Avatar.tsx +53 -0
  63. package/src/primitives/Button.tsx +30 -0
  64. package/src/primitives/Card.tsx +41 -0
  65. package/src/primitives/Checkbox.tsx +78 -0
  66. package/src/primitives/CountBadge.tsx +50 -0
  67. package/src/primitives/Input.tsx +71 -0
  68. package/src/primitives/Kbd.tsx +45 -0
  69. package/src/primitives/PageHeader.tsx +77 -0
  70. package/src/primitives/Tag.tsx +56 -0
  71. package/src/primitives/Textarea.tsx +62 -0
  72. package/src/primitives/ToggleSwitch.tsx +79 -0
  73. package/src/primitives/Tooltip.tsx +171 -0
  74. package/src/primitives/index.ts +24 -0
  75. package/src/pwa/InstallPromptBanner.tsx +132 -0
  76. package/src/pwa/index.ts +4 -0
  77. package/src/pwa/manifest.template.json +20 -0
  78. package/src/pwa/registerSW.ts +55 -0
  79. package/src/riso/Halftone.tsx +85 -0
  80. package/src/riso/Misregister.tsx +63 -0
  81. package/src/riso/RisoStamp.tsx +76 -0
  82. package/src/riso/index.ts +3 -0
  83. package/src/sketch/HandUnderline.tsx +53 -0
  84. package/src/sketch/RoughArrow.tsx +91 -0
  85. package/src/sketch/RoughBox.tsx +73 -0
  86. package/src/sketch/StickyNote.tsx +56 -0
  87. package/src/sketch/index.ts +4 -0
  88. package/src/styles/base.css +80 -0
  89. package/src/styles/chrome-ambient.css +222 -0
  90. package/src/styles/chrome-bento.css +184 -0
  91. package/src/styles/chrome-editorial.css +145 -0
  92. package/src/styles/chrome-frutiger.css +364 -0
  93. package/src/styles/chrome-neobrutalism.css +315 -0
  94. package/src/styles/chrome-riso.css +328 -0
  95. package/src/styles/chrome-sketch.css +351 -0
  96. package/src/styles/chrome-swiss.css +232 -0
  97. package/src/styles/chrome-terminal.css +235 -0
  98. package/src/styles/fonts.css +22 -0
  99. package/src/styles/index.css +198 -0
  100. package/src/styles/tokens.css +976 -0
  101. package/src/terminal/AsciiBox.tsx +65 -0
  102. package/src/terminal/BrailleSpinner.tsx +46 -0
  103. package/src/terminal/index.ts +4 -0
  104. package/src/theme/ThemeProvider.tsx +93 -0
  105. package/src/theme/index.ts +5 -0
  106. package/src/theme/inline-script.ts +36 -0
  107. package/src/theme/theme-context.ts +7 -0
  108. package/src/theme/types.ts +22 -0
  109. package/src/theme/useTheme.ts +8 -0
@@ -0,0 +1,277 @@
1
+ import {
2
+ useContext,
3
+ useEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type CSSProperties,
8
+ } from "react";
9
+ import { createPortal } from "react-dom";
10
+ import { CommandContext } from "./command-context";
11
+ import { rankItems } from "./rank";
12
+ import { Kbd } from "../primitives/Kbd";
13
+ import type { CommandItem } from "./types";
14
+
15
+ // The palette UI. Mounts inside <CommandProvider>; reads items from
16
+ // the registry, filters, groups, navigates. Arrow keys + Enter +
17
+ // Esc bound while open. Cmd+K toggle lives in the provider.
18
+
19
+ export interface CommandPaletteProps {
20
+ // Order in which groups appear. Items in groups not listed
21
+ // here come last in registration order.
22
+ groupOrder?: string[];
23
+ placeholder?: string;
24
+ }
25
+
26
+ export function CommandPalette({
27
+ groupOrder = [],
28
+ placeholder = "Type a command or search…",
29
+ }: CommandPaletteProps) {
30
+ const ctx = useContext(CommandContext);
31
+ if (!ctx) throw new Error("<CommandPalette> must be inside <CommandProvider>");
32
+ const { open, setOpen, items } = ctx;
33
+ const [q, setQ] = useState("");
34
+ const [selectedIdx, setSelectedIdx] = useState(0);
35
+ const inputRef = useRef<HTMLInputElement>(null);
36
+ const listRef = useRef<HTMLDivElement>(null);
37
+
38
+ // Reset query + focus on every open.
39
+ useEffect(() => {
40
+ if (open) {
41
+ setQ("");
42
+ setSelectedIdx(0);
43
+ // RAF so the modal element exists before focus.
44
+ requestAnimationFrame(() => inputRef.current?.focus());
45
+ }
46
+ }, [open]);
47
+
48
+ // Filter + flat-group ordering.
49
+ const filtered = useMemo(() => rankItems(items, q), [items, q]);
50
+ const grouped = useMemo(() => {
51
+ const byGroup: Record<string, CommandItem[]> = {};
52
+ for (const it of filtered) {
53
+ (byGroup[it.group] ??= []).push(it);
54
+ }
55
+ const seen = new Set<string>();
56
+ const ordered: { group: string; items: CommandItem[] }[] = [];
57
+ for (const g of groupOrder) {
58
+ if (byGroup[g]) {
59
+ ordered.push({ group: g, items: byGroup[g] });
60
+ seen.add(g);
61
+ }
62
+ }
63
+ for (const g of Object.keys(byGroup)) {
64
+ if (seen.has(g)) continue;
65
+ ordered.push({ group: g, items: byGroup[g]! });
66
+ }
67
+ return ordered;
68
+ }, [filtered, groupOrder]);
69
+
70
+ // Flat array for keyboard navigation (sequential across groups).
71
+ const flat = useMemo(() => grouped.flatMap((g) => g.items), [grouped]);
72
+
73
+ // Clamp selectedIdx when results shrink.
74
+ useEffect(() => {
75
+ if (selectedIdx >= flat.length) setSelectedIdx(Math.max(0, flat.length - 1));
76
+ }, [flat.length, selectedIdx]);
77
+
78
+ const close = () => setOpen(false);
79
+
80
+ useEffect(() => {
81
+ if (!open) return;
82
+ const onKey = (e: KeyboardEvent) => {
83
+ if (e.key === "Escape") {
84
+ e.preventDefault();
85
+ close();
86
+ return;
87
+ }
88
+ if (e.key === "ArrowDown") {
89
+ e.preventDefault();
90
+ setSelectedIdx((i) => (i + 1) % Math.max(1, flat.length));
91
+ return;
92
+ }
93
+ if (e.key === "ArrowUp") {
94
+ e.preventDefault();
95
+ setSelectedIdx((i) => (i - 1 + Math.max(1, flat.length)) % Math.max(1, flat.length));
96
+ return;
97
+ }
98
+ if (e.key === "Enter") {
99
+ e.preventDefault();
100
+ const pick = flat[selectedIdx];
101
+ if (pick) pick.run({ close });
102
+ return;
103
+ }
104
+ };
105
+ window.addEventListener("keydown", onKey);
106
+ return () => window.removeEventListener("keydown", onKey);
107
+ // close + flat + selectedIdx are stable enough; intentionally omit
108
+ // close from deps so we don't re-bind every render.
109
+ // eslint-disable-next-line react-hooks/exhaustive-deps
110
+ }, [open, flat, selectedIdx]);
111
+
112
+ // Scroll selected row into view.
113
+ useEffect(() => {
114
+ if (!open) return;
115
+ const node = listRef.current?.querySelector(`[data-row-idx="${selectedIdx}"]`);
116
+ (node as HTMLElement | null)?.scrollIntoView({ block: "nearest" });
117
+ }, [open, selectedIdx]);
118
+
119
+ if (!open) return null;
120
+
121
+ return createPortal(
122
+ <div
123
+ role="dialog"
124
+ aria-modal="true"
125
+ aria-label="Command palette"
126
+ style={{
127
+ position: "fixed",
128
+ inset: 0,
129
+ background: "rgba(10, 14, 26, 0.5)",
130
+ backdropFilter: "blur(2px)",
131
+ display: "flex",
132
+ alignItems: "flex-start",
133
+ justifyContent: "center",
134
+ padding: "10vh 16px 16px",
135
+ zIndex: 110,
136
+ }}
137
+ onMouseDown={(e) => {
138
+ if (e.target === e.currentTarget) close();
139
+ }}
140
+ className="ck-anim-fade"
141
+ >
142
+ <div
143
+ className="ck-anim-popover"
144
+ style={
145
+ {
146
+ width: "100%",
147
+ maxWidth: 560,
148
+ background: "var(--ck-bg-surface)",
149
+ border: "1px solid var(--ck-border-subtle)",
150
+ borderRadius: "var(--ck-radius-md)",
151
+ boxShadow: "var(--ck-shadow-3)",
152
+ overflow: "hidden",
153
+ display: "flex",
154
+ flexDirection: "column",
155
+ maxHeight: "70vh",
156
+ fontFamily: "var(--ck-font-sans)",
157
+ color: "var(--ck-text-primary)",
158
+ } as CSSProperties
159
+ }
160
+ >
161
+ <input
162
+ ref={inputRef}
163
+ value={q}
164
+ onChange={(e) => {
165
+ setQ(e.target.value);
166
+ setSelectedIdx(0);
167
+ }}
168
+ placeholder={placeholder}
169
+ style={{
170
+ height: 44,
171
+ padding: "0 16px",
172
+ border: "none",
173
+ borderBottom: "1px solid var(--ck-border-subtle)",
174
+ background: "transparent",
175
+ color: "var(--ck-text-primary)",
176
+ font: "400 14px/1 var(--ck-font-sans)",
177
+ outline: "none",
178
+ }}
179
+ />
180
+ <div ref={listRef} style={{ flex: 1, minHeight: 0, overflowY: "auto", padding: 4 }}>
181
+ {flat.length === 0 ? (
182
+ <div
183
+ style={{
184
+ padding: "24px 16px",
185
+ color: "var(--ck-text-tertiary)",
186
+ fontSize: 13,
187
+ textAlign: "center",
188
+ }}
189
+ >
190
+ No matches.
191
+ </div>
192
+ ) : (
193
+ grouped.map((g) => (
194
+ <div key={g.group} style={{ marginBottom: 6 }}>
195
+ <div
196
+ className="ck-eyebrow"
197
+ style={{ padding: "8px 12px 4px", color: "var(--ck-text-tertiary)" }}
198
+ >
199
+ {g.group}
200
+ </div>
201
+ {g.items.map((it) => {
202
+ const idx = flat.indexOf(it);
203
+ const selected = idx === selectedIdx;
204
+ return (
205
+ <div
206
+ key={it.key}
207
+ data-row-idx={idx}
208
+ onMouseEnter={() => setSelectedIdx(idx)}
209
+ onClick={() => it.run({ close })}
210
+ style={{
211
+ display: "flex",
212
+ alignItems: "center",
213
+ gap: 10,
214
+ padding: "8px 12px",
215
+ borderRadius: "var(--ck-radius-sm)",
216
+ background: selected ? "var(--ck-accent-muted)" : "transparent",
217
+ color: selected ? "var(--ck-accent)" : "var(--ck-text-primary)",
218
+ cursor: "pointer",
219
+ font: "400 13px/1.3 var(--ck-font-sans)",
220
+ }}
221
+ >
222
+ {it.icon && (
223
+ <span style={{ display: "inline-flex", color: "var(--ck-text-tertiary)" }}>
224
+ {it.icon}
225
+ </span>
226
+ )}
227
+ <span style={{ flex: 1, minWidth: 0, overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
228
+ {it.label}
229
+ {it.sublabel && (
230
+ <span style={{ color: "var(--ck-text-tertiary)", marginLeft: 6, fontSize: 12 }}>
231
+ {it.sublabel}
232
+ </span>
233
+ )}
234
+ </span>
235
+ {it.hint && (
236
+ <span style={{ color: "var(--ck-text-tertiary)", fontSize: 12 }}>
237
+ {it.hint}
238
+ </span>
239
+ )}
240
+ </div>
241
+ );
242
+ })}
243
+ </div>
244
+ ))
245
+ )}
246
+ </div>
247
+ <div
248
+ style={{
249
+ padding: "8px 12px",
250
+ borderTop: "1px solid var(--ck-border-subtle)",
251
+ display: "flex",
252
+ alignItems: "center",
253
+ gap: 12,
254
+ color: "var(--ck-text-tertiary)",
255
+ font: "400 11px/1 var(--ck-font-mono)",
256
+ background: "var(--ck-bg-surface-2)",
257
+ }}
258
+ >
259
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
260
+ <Kbd>↑</Kbd>
261
+ <Kbd>↓</Kbd>
262
+ navigate
263
+ </span>
264
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
265
+ <Kbd>Enter</Kbd>
266
+ run
267
+ </span>
268
+ <span style={{ display: "inline-flex", alignItems: "center", gap: 4 }}>
269
+ <Kbd>Esc</Kbd>
270
+ close
271
+ </span>
272
+ </div>
273
+ </div>
274
+ </div>,
275
+ document.body,
276
+ );
277
+ }
@@ -0,0 +1,57 @@
1
+ import { useCallback, useMemo, useState, type ReactNode } from "react";
2
+ import { CommandContext } from "./command-context";
3
+ import { useKeyboardHotkey } from "../hooks/useKeyboardHotkey";
4
+ import type { CommandItem } from "./types";
5
+
6
+ // Hosts the command-source registry + the palette's open state.
7
+ // Binds Cmd+K globally so any consumer page can trigger the palette
8
+ // without wiring its own listener.
9
+
10
+ export function CommandProvider({
11
+ children,
12
+ hotkey = "k",
13
+ }: {
14
+ children: ReactNode;
15
+ // Letter for the Mod+_ shortcut. Default "k".
16
+ hotkey?: string;
17
+ }) {
18
+ const [sources, setSources] = useState<Record<string, CommandItem[]>>({});
19
+ const [open, setOpen] = useState(false);
20
+
21
+ const register = useCallback((sourceKey: string, items: CommandItem[]) => {
22
+ setSources((prev) => {
23
+ const cur = prev[sourceKey];
24
+ if (cur && cur.length === items.length && cur.every((it, i) => it === items[i])) {
25
+ return prev;
26
+ }
27
+ return { ...prev, [sourceKey]: items };
28
+ });
29
+ }, []);
30
+
31
+ const unregister = useCallback((sourceKey: string) => {
32
+ setSources((prev) => {
33
+ if (!(sourceKey in prev)) return prev;
34
+ const next = { ...prev };
35
+ delete next[sourceKey];
36
+ return next;
37
+ });
38
+ }, []);
39
+
40
+ const items = useMemo(() => Object.values(sources).flat(), [sources]);
41
+
42
+ useKeyboardHotkey(
43
+ hotkey,
44
+ (e) => {
45
+ e.preventDefault();
46
+ setOpen((v) => !v);
47
+ },
48
+ { mod: true, skipWhenModalOpen: false },
49
+ );
50
+
51
+ const value = useMemo(
52
+ () => ({ register, unregister, items, open, setOpen }),
53
+ [register, unregister, items, open],
54
+ );
55
+
56
+ return <CommandContext.Provider value={value}>{children}</CommandContext.Provider>;
57
+ }
@@ -0,0 +1,12 @@
1
+ import { createContext } from "react";
2
+ import type { CommandItem } from "./types";
3
+
4
+ export interface CommandContextValue {
5
+ register: (sourceKey: string, items: CommandItem[]) => void;
6
+ unregister: (sourceKey: string) => void;
7
+ items: CommandItem[];
8
+ open: boolean;
9
+ setOpen: (next: boolean) => void;
10
+ }
11
+
12
+ export const CommandContext = createContext<CommandContextValue | null>(null);
@@ -0,0 +1,6 @@
1
+ export { CommandProvider } from "./CommandProvider";
2
+ export { CommandPalette } from "./CommandPalette";
3
+ export type { CommandPaletteProps } from "./CommandPalette";
4
+ export { useCommandSource, useCommandPalette } from "./useCommandSource";
5
+ export { rankItems } from "./rank";
6
+ export type { CommandItem } from "./types";
@@ -0,0 +1,45 @@
1
+ import type { CommandItem } from "./types";
2
+
3
+ // Tiny fuzzy ranker. Three signals:
4
+ // - label substring match → score 100
5
+ // - label prefix match → score +20
6
+ // - keyword substring match → score 60 (lower than label)
7
+ // Anything that doesn't match returns null.
8
+ //
9
+ // Not as fancy as fzf — good enough for command palettes with
10
+ // dozens to low hundreds of items. Swap in a fancier library if
11
+ // you outgrow it; the call site (`useFilteredCommands`) is small.
12
+
13
+ interface Ranked {
14
+ item: CommandItem;
15
+ score: number;
16
+ }
17
+
18
+ function scoreOne(item: CommandItem, q: string): number | null {
19
+ if (!q) return 1; // empty query — everyone passes, sorted by registration order
20
+ const ql = q.toLowerCase();
21
+ const lab = item.label.toLowerCase();
22
+ let s = 0;
23
+ if (lab.includes(ql)) {
24
+ s = 100;
25
+ if (lab.startsWith(ql)) s += 20;
26
+ } else if (item.keywords?.some((k) => k.toLowerCase().includes(ql))) {
27
+ s = 60;
28
+ } else if (item.sublabel?.toLowerCase().includes(ql)) {
29
+ s = 40;
30
+ } else {
31
+ return null;
32
+ }
33
+ return s;
34
+ }
35
+
36
+ export function rankItems(items: CommandItem[], query: string): CommandItem[] {
37
+ const ranked: Ranked[] = [];
38
+ for (const item of items) {
39
+ const sc = scoreOne(item, query);
40
+ if (sc !== null) ranked.push({ item, score: sc });
41
+ }
42
+ // Stable sort by score descending.
43
+ ranked.sort((a, b) => b.score - a.score);
44
+ return ranked.map((r) => r.item);
45
+ }
@@ -0,0 +1,26 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ // Single executable command. Sources register sets of these; the
4
+ // palette flattens, filters by the user's query, groups by `group`,
5
+ // and runs `run()` when the user picks.
6
+
7
+ export interface CommandItem {
8
+ key: string;
9
+ // Visual category — items sharing a group render under a single
10
+ // labelled header in the palette. Use anything; the kit pins
11
+ // ordering via the palette's `groupOrder` prop.
12
+ group: string;
13
+ label: string;
14
+ // Dimmed secondary text — usually a path or short context.
15
+ sublabel?: string;
16
+ // Right-aligned hint (kbd shortcut, "→", etc.).
17
+ hint?: ReactNode;
18
+ icon?: ReactNode;
19
+ // Extra strings to score against (filenames, aliases). Lower
20
+ // weight than label so they don't drown the primary match.
21
+ keywords?: string[];
22
+ // Called when the user picks this command. `close()` shuts the
23
+ // palette — useful when the command opens a sub-flow that wants
24
+ // to keep the palette open.
25
+ run: (api: { close: () => void }) => void;
26
+ }
@@ -0,0 +1,37 @@
1
+ import { useContext, useEffect, useMemo } from "react";
2
+ import { CommandContext } from "./command-context";
3
+ import type { CommandItem } from "./types";
4
+
5
+ // Register a set of commands. Same lifecycle pattern as
6
+ // useActionBarItems — auto-unregister on unmount / sourceKey change.
7
+ //
8
+ // **Important**: same `ctx`-in-deps caveat as useActionBarItems —
9
+ // depending on the whole ctx object would cause an infinite render
10
+ // loop (register mutates provider state → ctx ref changes → effect
11
+ // re-runs → loop). Destructure register/unregister out so we depend
12
+ // on the stable useCallback'd refs instead.
13
+
14
+ export function useCommandSource(sourceKey: string, items: CommandItem[]) {
15
+ const ctx = useContext(CommandContext);
16
+ if (!ctx) {
17
+ throw new Error("useCommandSource must be used within <CommandProvider>");
18
+ }
19
+ const { register, unregister } = ctx;
20
+ const memo = useMemo(
21
+ () => items,
22
+ // eslint-disable-next-line react-hooks/exhaustive-deps
23
+ [items.length, items.map((i) => i.key).join("|")],
24
+ );
25
+ useEffect(() => {
26
+ register(sourceKey, memo);
27
+ return () => unregister(sourceKey);
28
+ }, [register, unregister, sourceKey, memo]);
29
+ }
30
+
31
+ export function useCommandPalette() {
32
+ const ctx = useContext(CommandContext);
33
+ if (!ctx) {
34
+ throw new Error("Command palette context not found");
35
+ }
36
+ return { open: ctx.open, setOpen: ctx.setOpen };
37
+ }
@@ -0,0 +1,216 @@
1
+ import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
2
+ import { DialogsContext } from "./dialogs-context";
3
+ import { Modal, ModalHeader, ModalBody, ModalFooter } from "./Modal";
4
+ import { Button } from "../primitives/Button";
5
+ import { Input } from "../primitives/Input";
6
+ import { ToastStack, type ToastItem } from "./Toast";
7
+ import type {
8
+ ConfirmOptions,
9
+ PromptOptions,
10
+ ToastOptions,
11
+ DialogsApi,
12
+ } from "./types";
13
+
14
+ // Single mount point. Hosts the imperative confirm/prompt/toast
15
+ // API used by useDialogs(). Promise-based so call sites flow
16
+ // naturally:
17
+ //
18
+ // const ok = await dialogs.confirm({ title: "Delete?", danger: true });
19
+ // if (!ok) return;
20
+ // await api.deleteThing();
21
+ //
22
+ // Toast queue handles its own dismiss timer (see Toast.tsx).
23
+
24
+ interface ConfirmState extends ConfirmOptions {
25
+ resolve: (value: boolean) => void;
26
+ }
27
+ interface PromptState extends PromptOptions {
28
+ resolve: (value: string | null) => void;
29
+ }
30
+
31
+ export function DialogsProvider({ children }: { children: ReactNode }) {
32
+ const [confirmState, setConfirmState] = useState<ConfirmState | null>(null);
33
+ const [promptState, setPromptState] = useState<PromptState | null>(null);
34
+ const [toasts, setToasts] = useState<ToastItem[]>([]);
35
+ const toastIdRef = useRef(0);
36
+
37
+ const confirm = useCallback(
38
+ (opts: ConfirmOptions) =>
39
+ new Promise<boolean>((resolve) => {
40
+ setConfirmState({ ...opts, resolve });
41
+ }),
42
+ [],
43
+ );
44
+
45
+ const prompt = useCallback(
46
+ (opts: PromptOptions) =>
47
+ new Promise<string | null>((resolve) => {
48
+ setPromptState({ ...opts, resolve });
49
+ }),
50
+ [],
51
+ );
52
+
53
+ const toast = useCallback((opts: ToastOptions) => {
54
+ const id = ++toastIdRef.current;
55
+ setToasts((t) => [...t, { ...opts, id }]);
56
+ }, []);
57
+
58
+ const dismissToast = useCallback((id: number) => {
59
+ setToasts((t) => t.filter((x) => x.id !== id));
60
+ }, []);
61
+
62
+ const api = useMemo<DialogsApi>(
63
+ () => ({ confirm, prompt, toast }),
64
+ [confirm, prompt, toast],
65
+ );
66
+
67
+ return (
68
+ <DialogsContext.Provider value={api}>
69
+ {children}
70
+ {confirmState && (
71
+ <ConfirmDialog
72
+ state={confirmState}
73
+ onDone={(v) => {
74
+ confirmState.resolve(v);
75
+ setConfirmState(null);
76
+ }}
77
+ />
78
+ )}
79
+ {promptState && (
80
+ <PromptDialog
81
+ state={promptState}
82
+ onDone={(v) => {
83
+ promptState.resolve(v);
84
+ setPromptState(null);
85
+ }}
86
+ />
87
+ )}
88
+ <ToastStack toasts={toasts} onDismiss={dismissToast} />
89
+ </DialogsContext.Provider>
90
+ );
91
+ }
92
+
93
+ // ---------- Confirm dialog ----------
94
+
95
+ function ConfirmDialog({
96
+ state,
97
+ onDone,
98
+ }: {
99
+ state: ConfirmState;
100
+ onDone: (value: boolean) => void;
101
+ }) {
102
+ const [typed, setTyped] = useState("");
103
+ const needsTyped = !!state.confirmationText;
104
+ const canConfirm =
105
+ !needsTyped ||
106
+ typed.trim().toLowerCase() === state.confirmationText!.trim().toLowerCase();
107
+ return (
108
+ <Modal open onClose={() => onDone(false)}>
109
+ <ModalHeader title={state.title} onClose={() => onDone(false)} />
110
+ {(state.message || needsTyped) && (
111
+ <ModalBody>
112
+ {state.message && (
113
+ <div
114
+ style={{
115
+ font: "400 14px/1.5 var(--ck-font-sans)",
116
+ color: "var(--ck-text-secondary)",
117
+ }}
118
+ >
119
+ {state.message}
120
+ </div>
121
+ )}
122
+ {needsTyped && (
123
+ <div style={{ marginTop: 16 }}>
124
+ <Input
125
+ value={typed}
126
+ onChange={(e) => setTyped(e.target.value)}
127
+ placeholder={state.confirmationText}
128
+ helper={`Type "${state.confirmationText}" to confirm.`}
129
+ />
130
+ </div>
131
+ )}
132
+ </ModalBody>
133
+ )}
134
+ <ModalFooter>
135
+ <Button variant="ghost" onClick={() => onDone(false)}>
136
+ {state.cancelLabel ?? "Cancel"}
137
+ </Button>
138
+ <Button
139
+ variant="primary"
140
+ onClick={() => onDone(true)}
141
+ disabled={!canConfirm}
142
+ style={
143
+ state.danger
144
+ ? {
145
+ background: "var(--ck-critical)",
146
+ color: "var(--ck-text-inverse)",
147
+ }
148
+ : undefined
149
+ }
150
+ >
151
+ {state.confirmLabel ?? "Confirm"}
152
+ </Button>
153
+ </ModalFooter>
154
+ </Modal>
155
+ );
156
+ }
157
+
158
+ // ---------- Prompt dialog ----------
159
+
160
+ function PromptDialog({
161
+ state,
162
+ onDone,
163
+ }: {
164
+ state: PromptState;
165
+ onDone: (value: string | null) => void;
166
+ }) {
167
+ const [value, setValue] = useState(state.defaultValue ?? "");
168
+ const [error, setError] = useState<string | null>(null);
169
+ const submit = () => {
170
+ const err = state.validate?.(value);
171
+ if (err) {
172
+ setError(err);
173
+ return;
174
+ }
175
+ onDone(value);
176
+ };
177
+ return (
178
+ <Modal open onClose={() => onDone(null)}>
179
+ <ModalHeader title={state.title} onClose={() => onDone(null)} />
180
+ <ModalBody>
181
+ {state.message && (
182
+ <div
183
+ style={{
184
+ font: "400 14px/1.5 var(--ck-font-sans)",
185
+ color: "var(--ck-text-secondary)",
186
+ marginBottom: 12,
187
+ }}
188
+ >
189
+ {state.message}
190
+ </div>
191
+ )}
192
+ <Input
193
+ value={value}
194
+ onChange={(e) => {
195
+ setValue(e.target.value);
196
+ if (error) setError(null);
197
+ }}
198
+ placeholder={state.placeholder}
199
+ error={error}
200
+ onKeyDown={(e) => {
201
+ if (e.key === "Enter") submit();
202
+ }}
203
+ autoFocus
204
+ />
205
+ </ModalBody>
206
+ <ModalFooter>
207
+ <Button variant="ghost" onClick={() => onDone(null)}>
208
+ {state.cancelLabel ?? "Cancel"}
209
+ </Button>
210
+ <Button variant="primary" onClick={submit}>
211
+ {state.confirmLabel ?? "OK"}
212
+ </Button>
213
+ </ModalFooter>
214
+ </Modal>
215
+ );
216
+ }