@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.
- package/package.json +38 -0
- package/src/actionbar/ActionBar.tsx +436 -0
- package/src/actionbar/ActionBarButton.tsx +110 -0
- package/src/actionbar/ActionBarMenuGroup.tsx +106 -0
- package/src/actionbar/ActionBarProvider.tsx +76 -0
- package/src/actionbar/actionbar-context.ts +23 -0
- package/src/actionbar/index.ts +13 -0
- package/src/actionbar/types.ts +50 -0
- package/src/actionbar/useActionBarItems.ts +47 -0
- package/src/ambient/AmbientBackdrop.tsx +74 -0
- package/src/ambient/CommandInput.tsx +107 -0
- package/src/ambient/SuperbarStrip.tsx +36 -0
- package/src/ambient/index.ts +6 -0
- package/src/bento/BentoCell.tsx +66 -0
- package/src/bento/BentoGrid.tsx +42 -0
- package/src/bento/index.ts +2 -0
- package/src/command/CommandPalette.tsx +277 -0
- package/src/command/CommandProvider.tsx +57 -0
- package/src/command/command-context.ts +12 -0
- package/src/command/index.ts +6 -0
- package/src/command/rank.ts +45 -0
- package/src/command/types.ts +26 -0
- package/src/command/useCommandSource.ts +37 -0
- package/src/dialogs/DialogsProvider.tsx +216 -0
- package/src/dialogs/Modal.tsx +204 -0
- package/src/dialogs/Toast.tsx +85 -0
- package/src/dialogs/dialogs-context.ts +6 -0
- package/src/dialogs/index.ts +10 -0
- package/src/dialogs/types.ts +37 -0
- package/src/dialogs/useDialogs.ts +8 -0
- package/src/editorial/EditorialSpotlight.tsx +63 -0
- package/src/editorial/Folio.tsx +52 -0
- package/src/editorial/PlateMarker.tsx +33 -0
- package/src/editorial/RomanSection.tsx +65 -0
- package/src/editorial/RunningMarginalia.tsx +65 -0
- package/src/editorial/index.ts +10 -0
- package/src/frutiger/GlossyOrb.tsx +79 -0
- package/src/frutiger/SkyBackdrop.tsx +114 -0
- package/src/frutiger/index.ts +2 -0
- package/src/hooks/index.ts +5 -0
- package/src/hooks/useKeyboardHotkey.ts +80 -0
- package/src/hooks/useReducedMotion.ts +20 -0
- package/src/hooks/useViewport.ts +61 -0
- package/src/index.ts +26 -0
- package/src/layout/Breadcrumb.tsx +74 -0
- package/src/layout/LeftNavRail.tsx +126 -0
- package/src/layout/MobileTabBar.tsx +101 -0
- package/src/layout/NavItem.tsx +128 -0
- package/src/layout/NavSearchTrigger.tsx +88 -0
- package/src/layout/NavSection.tsx +40 -0
- package/src/layout/RightSidebarPanel.tsx +111 -0
- package/src/layout/Shell.tsx +91 -0
- package/src/layout/StickyBanner.tsx +83 -0
- package/src/layout/Topbar.tsx +68 -0
- package/src/layout/index.ts +22 -0
- package/src/layout/useNavRailState.ts +69 -0
- package/src/lib/cn.ts +7 -0
- package/src/lib/time-utils.ts +44 -0
- package/src/neobrutalism/Marquee.tsx +81 -0
- package/src/neobrutalism/Sticker.tsx +71 -0
- package/src/neobrutalism/index.ts +4 -0
- package/src/primitives/Avatar.tsx +53 -0
- package/src/primitives/Button.tsx +30 -0
- package/src/primitives/Card.tsx +41 -0
- package/src/primitives/Checkbox.tsx +78 -0
- package/src/primitives/CountBadge.tsx +50 -0
- package/src/primitives/Input.tsx +71 -0
- package/src/primitives/Kbd.tsx +45 -0
- package/src/primitives/PageHeader.tsx +77 -0
- package/src/primitives/Tag.tsx +56 -0
- package/src/primitives/Textarea.tsx +62 -0
- package/src/primitives/ToggleSwitch.tsx +79 -0
- package/src/primitives/Tooltip.tsx +171 -0
- package/src/primitives/index.ts +24 -0
- package/src/pwa/InstallPromptBanner.tsx +132 -0
- package/src/pwa/index.ts +4 -0
- package/src/pwa/manifest.template.json +20 -0
- package/src/pwa/registerSW.ts +55 -0
- package/src/riso/Halftone.tsx +85 -0
- package/src/riso/Misregister.tsx +63 -0
- package/src/riso/RisoStamp.tsx +76 -0
- package/src/riso/index.ts +3 -0
- package/src/sketch/HandUnderline.tsx +53 -0
- package/src/sketch/RoughArrow.tsx +91 -0
- package/src/sketch/RoughBox.tsx +73 -0
- package/src/sketch/StickyNote.tsx +56 -0
- package/src/sketch/index.ts +4 -0
- package/src/styles/base.css +80 -0
- package/src/styles/chrome-ambient.css +222 -0
- package/src/styles/chrome-bento.css +184 -0
- package/src/styles/chrome-editorial.css +145 -0
- package/src/styles/chrome-frutiger.css +364 -0
- package/src/styles/chrome-neobrutalism.css +315 -0
- package/src/styles/chrome-riso.css +328 -0
- package/src/styles/chrome-sketch.css +351 -0
- package/src/styles/chrome-swiss.css +232 -0
- package/src/styles/chrome-terminal.css +235 -0
- package/src/styles/fonts.css +22 -0
- package/src/styles/index.css +198 -0
- package/src/styles/tokens.css +976 -0
- package/src/terminal/AsciiBox.tsx +65 -0
- package/src/terminal/BrailleSpinner.tsx +46 -0
- package/src/terminal/index.ts +4 -0
- package/src/theme/ThemeProvider.tsx +93 -0
- package/src/theme/index.ts +5 -0
- package/src/theme/inline-script.ts +36 -0
- package/src/theme/theme-context.ts +7 -0
- package/src/theme/types.ts +22 -0
- package/src/theme/useTheme.ts +8 -0
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { forwardRef, useId, type InputHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
// Styled text input with optional label + error + helper text.
|
|
5
|
+
// Three states: idle / focused / invalid. Focus ring + invalid border
|
|
6
|
+
// both via :focus-visible / aria-invalid CSS — no JS branching.
|
|
7
|
+
|
|
8
|
+
export interface InputProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "size"> {
|
|
9
|
+
label?: ReactNode;
|
|
10
|
+
helper?: ReactNode;
|
|
11
|
+
// Validation error message. When set, the input renders an
|
|
12
|
+
// invalid border + colour and the helper slot is replaced by
|
|
13
|
+
// the error text.
|
|
14
|
+
error?: string | null;
|
|
15
|
+
// Width preset — full, fixed, or auto (default full).
|
|
16
|
+
fit?: "full" | "auto";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
|
|
20
|
+
{ label, helper, error, fit = "full", className, id, ...rest },
|
|
21
|
+
ref,
|
|
22
|
+
) {
|
|
23
|
+
const autoId = useId();
|
|
24
|
+
const inputId = id ?? autoId;
|
|
25
|
+
return (
|
|
26
|
+
<div
|
|
27
|
+
className={cn("ck-input-field", className)}
|
|
28
|
+
style={{ display: "flex", flexDirection: "column", gap: 6, width: fit === "full" ? "100%" : undefined }}
|
|
29
|
+
>
|
|
30
|
+
{label && (
|
|
31
|
+
<label
|
|
32
|
+
htmlFor={inputId}
|
|
33
|
+
className="ck-eyebrow"
|
|
34
|
+
style={{ color: "var(--ck-text-secondary)" }}
|
|
35
|
+
>
|
|
36
|
+
{label}
|
|
37
|
+
</label>
|
|
38
|
+
)}
|
|
39
|
+
<input
|
|
40
|
+
ref={ref}
|
|
41
|
+
id={inputId}
|
|
42
|
+
aria-invalid={error ? true : undefined}
|
|
43
|
+
aria-describedby={(helper || error) ? `${inputId}-helper` : undefined}
|
|
44
|
+
{...rest}
|
|
45
|
+
className="ck-input"
|
|
46
|
+
style={{
|
|
47
|
+
height: 34,
|
|
48
|
+
padding: "0 12px",
|
|
49
|
+
font: "400 13px/1 var(--ck-font-sans)",
|
|
50
|
+
background: "var(--ck-bg-surface)",
|
|
51
|
+
color: "var(--ck-text-primary)",
|
|
52
|
+
border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
|
|
53
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
54
|
+
outline: "none",
|
|
55
|
+
transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
|
|
56
|
+
}}
|
|
57
|
+
/>
|
|
58
|
+
{(helper || error) && (
|
|
59
|
+
<div
|
|
60
|
+
id={`${inputId}-helper`}
|
|
61
|
+
style={{
|
|
62
|
+
font: "400 11px/1.4 var(--ck-font-sans)",
|
|
63
|
+
color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
|
|
64
|
+
}}
|
|
65
|
+
>
|
|
66
|
+
{error ?? helper}
|
|
67
|
+
</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
// Keyboard hint pill — ⌘K, ⌃K, Shift, etc. Auto-translates "Mod"
|
|
5
|
+
// to ⌘ on Mac and Ctrl elsewhere so docs strings can stay portable.
|
|
6
|
+
|
|
7
|
+
export interface KbdProps {
|
|
8
|
+
// "Mod" is replaced by ⌘ on Mac, Ctrl elsewhere. Otherwise rendered
|
|
9
|
+
// verbatim — pass "Shift", "Enter", "↑", "K", etc.
|
|
10
|
+
children: ReactNode;
|
|
11
|
+
className?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const isMac =
|
|
15
|
+
typeof navigator !== "undefined" &&
|
|
16
|
+
/Mac|iPhone|iPad|iPod/.test(navigator.platform);
|
|
17
|
+
|
|
18
|
+
function renderChild(c: ReactNode): ReactNode {
|
|
19
|
+
if (typeof c !== "string") return c;
|
|
20
|
+
if (c === "Mod") return isMac ? "⌘" : "Ctrl";
|
|
21
|
+
return c;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function Kbd({ children, className }: KbdProps) {
|
|
25
|
+
return (
|
|
26
|
+
<kbd
|
|
27
|
+
className={cn("ck-kbd", className)}
|
|
28
|
+
style={{
|
|
29
|
+
display: "inline-flex",
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
justifyContent: "center",
|
|
32
|
+
minWidth: 18,
|
|
33
|
+
height: 18,
|
|
34
|
+
padding: "0 5px",
|
|
35
|
+
background: "var(--ck-bg-muted)",
|
|
36
|
+
border: "1px solid var(--ck-border-subtle)",
|
|
37
|
+
color: "var(--ck-text-secondary)",
|
|
38
|
+
font: "500 11px/1 var(--ck-font-mono)",
|
|
39
|
+
borderRadius: "var(--ck-radius-xs)",
|
|
40
|
+
}}
|
|
41
|
+
>
|
|
42
|
+
{renderChild(children)}
|
|
43
|
+
</kbd>
|
|
44
|
+
);
|
|
45
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { ReactNode, CSSProperties } from "react";
|
|
2
|
+
|
|
3
|
+
// Standard list-page header — eyebrow + title + optional
|
|
4
|
+
// description + right-aligned action cluster. Wrap the top of
|
|
5
|
+
// list / overview pages so visual rhythm stays consistent across
|
|
6
|
+
// the app.
|
|
7
|
+
|
|
8
|
+
export interface PageHeaderProps {
|
|
9
|
+
// Small uppercase mono cap above the title — "ACCESS · SHARES".
|
|
10
|
+
eyebrow?: string;
|
|
11
|
+
// Main heading (typically a sentence: "Bundles you've sent.").
|
|
12
|
+
title: ReactNode;
|
|
13
|
+
// Optional one-or-two-line caption under the title.
|
|
14
|
+
description?: ReactNode;
|
|
15
|
+
// Right-aligned cluster of buttons / chips / status items.
|
|
16
|
+
actions?: ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
style?: CSSProperties;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function PageHeader({
|
|
22
|
+
eyebrow,
|
|
23
|
+
title,
|
|
24
|
+
description,
|
|
25
|
+
actions,
|
|
26
|
+
className,
|
|
27
|
+
style,
|
|
28
|
+
}: PageHeaderProps) {
|
|
29
|
+
return (
|
|
30
|
+
<header
|
|
31
|
+
className={className}
|
|
32
|
+
style={{
|
|
33
|
+
display: "flex",
|
|
34
|
+
alignItems: "flex-end",
|
|
35
|
+
gap: 16,
|
|
36
|
+
marginBottom: 24,
|
|
37
|
+
paddingBottom: 16,
|
|
38
|
+
borderBottom: "1px solid var(--ck-border-subtle)",
|
|
39
|
+
fontFamily: "var(--ck-font-sans)",
|
|
40
|
+
...style,
|
|
41
|
+
}}
|
|
42
|
+
>
|
|
43
|
+
<div style={{ flex: 1, minWidth: 0 }}>
|
|
44
|
+
{eyebrow && (
|
|
45
|
+
<div className="ck-eyebrow" style={{ marginBottom: 6 }}>
|
|
46
|
+
{eyebrow}
|
|
47
|
+
</div>
|
|
48
|
+
)}
|
|
49
|
+
<h1
|
|
50
|
+
style={{
|
|
51
|
+
font: "500 22px/1.3 var(--ck-font-sans)",
|
|
52
|
+
letterSpacing: "-0.005em",
|
|
53
|
+
color: "var(--ck-text-primary)",
|
|
54
|
+
margin: 0,
|
|
55
|
+
}}
|
|
56
|
+
>
|
|
57
|
+
{title}
|
|
58
|
+
</h1>
|
|
59
|
+
{description && (
|
|
60
|
+
<p
|
|
61
|
+
style={{
|
|
62
|
+
font: "400 13px/1.55 var(--ck-font-sans)",
|
|
63
|
+
color: "var(--ck-text-tertiary)",
|
|
64
|
+
margin: "6px 0 0",
|
|
65
|
+
maxWidth: "44rem",
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
{description}
|
|
69
|
+
</p>
|
|
70
|
+
)}
|
|
71
|
+
</div>
|
|
72
|
+
{actions && (
|
|
73
|
+
<div style={{ display: "flex", gap: 8, flex: "none" }}>{actions}</div>
|
|
74
|
+
)}
|
|
75
|
+
</header>
|
|
76
|
+
);
|
|
77
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
// Small uppercase-mono pill — "PENDING", "LIVE", "Accepted", "3 docs".
|
|
5
|
+
// Same vocabulary as ck-tag in base.css but with explicit tone variants
|
|
6
|
+
// instead of inline color overrides. Use this when the tag is a status
|
|
7
|
+
// indicator; use the bare .ck-tag class when it's just a caption.
|
|
8
|
+
|
|
9
|
+
export type TagTone = "neutral" | "accent" | "success" | "warning" | "critical";
|
|
10
|
+
|
|
11
|
+
export interface TagProps {
|
|
12
|
+
tone?: TagTone;
|
|
13
|
+
// Renders as a filled pill (background tinted) instead of a bare
|
|
14
|
+
// colored label. Default false.
|
|
15
|
+
filled?: boolean;
|
|
16
|
+
children: ReactNode;
|
|
17
|
+
className?: string;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const TONE_COLOR: Record<TagTone, string> = {
|
|
21
|
+
neutral: "var(--ck-text-tertiary)",
|
|
22
|
+
accent: "var(--ck-accent)",
|
|
23
|
+
success: "var(--ck-success)",
|
|
24
|
+
warning: "var(--ck-warning)",
|
|
25
|
+
critical: "var(--ck-critical)",
|
|
26
|
+
};
|
|
27
|
+
const TONE_BG: Record<TagTone, string> = {
|
|
28
|
+
neutral: "var(--ck-bg-muted)",
|
|
29
|
+
accent: "var(--ck-accent-muted)",
|
|
30
|
+
success: "var(--ck-success-muted)",
|
|
31
|
+
warning: "var(--ck-warning-muted)",
|
|
32
|
+
critical: "var(--ck-critical-muted)",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export function Tag({ tone = "neutral", filled = false, children, className }: TagProps) {
|
|
36
|
+
return (
|
|
37
|
+
<span
|
|
38
|
+
className={cn("ck-tag", className)}
|
|
39
|
+
data-ck-tag
|
|
40
|
+
data-tone={tone}
|
|
41
|
+
data-filled={filled ? "true" : undefined}
|
|
42
|
+
style={
|
|
43
|
+
filled
|
|
44
|
+
? {
|
|
45
|
+
color: TONE_COLOR[tone],
|
|
46
|
+
background: TONE_BG[tone],
|
|
47
|
+
padding: "3px 8px",
|
|
48
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
49
|
+
}
|
|
50
|
+
: { color: TONE_COLOR[tone] }
|
|
51
|
+
}
|
|
52
|
+
>
|
|
53
|
+
{children}
|
|
54
|
+
</span>
|
|
55
|
+
);
|
|
56
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { forwardRef, useId, type TextareaHTMLAttributes, type ReactNode } from "react";
|
|
2
|
+
import { cn } from "../lib/cn";
|
|
3
|
+
|
|
4
|
+
// Same shape as Input — label + helper + error — for multiline.
|
|
5
|
+
|
|
6
|
+
export interface TextareaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
|
7
|
+
label?: ReactNode;
|
|
8
|
+
helper?: ReactNode;
|
|
9
|
+
error?: string | null;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(
|
|
13
|
+
function Textarea({ label, helper, error, className, id, ...rest }, ref) {
|
|
14
|
+
const autoId = useId();
|
|
15
|
+
const tid = id ?? autoId;
|
|
16
|
+
return (
|
|
17
|
+
<div
|
|
18
|
+
className={cn("ck-textarea-field", className)}
|
|
19
|
+
style={{ display: "flex", flexDirection: "column", gap: 6 }}
|
|
20
|
+
>
|
|
21
|
+
{label && (
|
|
22
|
+
<label
|
|
23
|
+
htmlFor={tid}
|
|
24
|
+
className="ck-eyebrow"
|
|
25
|
+
style={{ color: "var(--ck-text-secondary)" }}
|
|
26
|
+
>
|
|
27
|
+
{label}
|
|
28
|
+
</label>
|
|
29
|
+
)}
|
|
30
|
+
<textarea
|
|
31
|
+
ref={ref}
|
|
32
|
+
id={tid}
|
|
33
|
+
aria-invalid={error ? true : undefined}
|
|
34
|
+
{...rest}
|
|
35
|
+
className="ck-textarea"
|
|
36
|
+
style={{
|
|
37
|
+
minHeight: 80,
|
|
38
|
+
padding: "8px 12px",
|
|
39
|
+
font: "400 13px/1.5 var(--ck-font-sans)",
|
|
40
|
+
background: "var(--ck-bg-surface)",
|
|
41
|
+
color: "var(--ck-text-primary)",
|
|
42
|
+
border: `1px solid ${error ? "var(--ck-critical)" : "var(--ck-border-strong)"}`,
|
|
43
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
44
|
+
outline: "none",
|
|
45
|
+
resize: "vertical",
|
|
46
|
+
transition: "border-color var(--ck-dur-fast) var(--ck-ease)",
|
|
47
|
+
}}
|
|
48
|
+
/>
|
|
49
|
+
{(helper || error) && (
|
|
50
|
+
<div
|
|
51
|
+
style={{
|
|
52
|
+
font: "400 11px/1.4 var(--ck-font-sans)",
|
|
53
|
+
color: error ? "var(--ck-critical)" : "var(--ck-text-tertiary)",
|
|
54
|
+
}}
|
|
55
|
+
>
|
|
56
|
+
{error ?? helper}
|
|
57
|
+
</div>
|
|
58
|
+
)}
|
|
59
|
+
</div>
|
|
60
|
+
);
|
|
61
|
+
},
|
|
62
|
+
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { cn } from "../lib/cn";
|
|
2
|
+
|
|
3
|
+
// iOS-style track + knob toggle. Off-state track uses
|
|
4
|
+
// --ck-text-secondary (not --ck-border-subtle which is near-invisible
|
|
5
|
+
// on dark backgrounds — same lesson learned in the parent project).
|
|
6
|
+
// Knob is fixed white so it reads on either accent or gray track.
|
|
7
|
+
|
|
8
|
+
export interface ToggleSwitchProps {
|
|
9
|
+
checked: boolean;
|
|
10
|
+
onChange: (next: boolean) => void;
|
|
11
|
+
label?: React.ReactNode;
|
|
12
|
+
suffix?: React.ReactNode;
|
|
13
|
+
disabled?: boolean;
|
|
14
|
+
className?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ToggleSwitch({
|
|
18
|
+
checked,
|
|
19
|
+
onChange,
|
|
20
|
+
label,
|
|
21
|
+
suffix,
|
|
22
|
+
disabled,
|
|
23
|
+
className,
|
|
24
|
+
}: ToggleSwitchProps) {
|
|
25
|
+
return (
|
|
26
|
+
<label
|
|
27
|
+
className={cn("ck-toggle", className)}
|
|
28
|
+
style={{
|
|
29
|
+
display: "inline-flex",
|
|
30
|
+
alignItems: "center",
|
|
31
|
+
gap: 8,
|
|
32
|
+
font: "400 13px/1 var(--ck-font-sans)",
|
|
33
|
+
color: "var(--ck-text-secondary)",
|
|
34
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
35
|
+
opacity: disabled ? 0.5 : 1,
|
|
36
|
+
userSelect: "none",
|
|
37
|
+
}}
|
|
38
|
+
>
|
|
39
|
+
<button
|
|
40
|
+
type="button"
|
|
41
|
+
role="switch"
|
|
42
|
+
aria-checked={checked}
|
|
43
|
+
disabled={disabled}
|
|
44
|
+
onClick={() => !disabled && onChange(!checked)}
|
|
45
|
+
style={{
|
|
46
|
+
position: "relative",
|
|
47
|
+
width: 34,
|
|
48
|
+
height: 20,
|
|
49
|
+
borderRadius: 999,
|
|
50
|
+
border: "1px solid var(--ck-text-tertiary)",
|
|
51
|
+
background: checked ? "var(--ck-accent)" : "var(--ck-text-secondary)",
|
|
52
|
+
padding: 0,
|
|
53
|
+
cursor: disabled ? "not-allowed" : "pointer",
|
|
54
|
+
flexShrink: 0,
|
|
55
|
+
transition: "background var(--ck-dur-fast) var(--ck-ease)",
|
|
56
|
+
}}
|
|
57
|
+
>
|
|
58
|
+
<span
|
|
59
|
+
aria-hidden
|
|
60
|
+
style={{
|
|
61
|
+
position: "absolute",
|
|
62
|
+
top: 2,
|
|
63
|
+
left: checked ? 16 : 2,
|
|
64
|
+
width: 14,
|
|
65
|
+
height: 14,
|
|
66
|
+
background: "#FFFFFF",
|
|
67
|
+
borderRadius: "50%",
|
|
68
|
+
boxShadow: "0 1px 3px rgba(0,0,0,0.35)",
|
|
69
|
+
transition: "left var(--ck-dur-fast) var(--ck-ease)",
|
|
70
|
+
}}
|
|
71
|
+
/>
|
|
72
|
+
</button>
|
|
73
|
+
{label}
|
|
74
|
+
{suffix && (
|
|
75
|
+
<span style={{ color: "var(--ck-text-tertiary)" }}>{suffix}</span>
|
|
76
|
+
)}
|
|
77
|
+
</label>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloneElement,
|
|
3
|
+
isValidElement,
|
|
4
|
+
useCallback,
|
|
5
|
+
useEffect,
|
|
6
|
+
useRef,
|
|
7
|
+
useState,
|
|
8
|
+
type CSSProperties,
|
|
9
|
+
type ReactElement,
|
|
10
|
+
type ReactNode,
|
|
11
|
+
} from "react";
|
|
12
|
+
import { createPortal } from "react-dom";
|
|
13
|
+
|
|
14
|
+
// Lightweight tooltip — replaces the native `title=` attribute when
|
|
15
|
+
// you want a short reveal delay (default 120 ms vs the OS' ~500 ms)
|
|
16
|
+
// and styling that matches the kit's chrome. Portals to body so it
|
|
17
|
+
// floats above transformed parents / fixed bars.
|
|
18
|
+
//
|
|
19
|
+
// Wraps a single React element child; mouse + focus listeners are
|
|
20
|
+
// attached via clone so the trigger keeps its own handlers.
|
|
21
|
+
|
|
22
|
+
export interface TooltipProps {
|
|
23
|
+
content: ReactNode;
|
|
24
|
+
children: ReactElement<{
|
|
25
|
+
onMouseEnter?: React.MouseEventHandler;
|
|
26
|
+
onMouseLeave?: React.MouseEventHandler;
|
|
27
|
+
onFocus?: React.FocusEventHandler;
|
|
28
|
+
onBlur?: React.FocusEventHandler;
|
|
29
|
+
}>;
|
|
30
|
+
// ms before showing. Default 120.
|
|
31
|
+
delay?: number;
|
|
32
|
+
// "top" hovers above the trigger, "bottom" below. Default "top".
|
|
33
|
+
placement?: "top" | "bottom";
|
|
34
|
+
// Render even if `content` is falsy. Default false — callers can
|
|
35
|
+
// wrap unconditionally without branching JSX.
|
|
36
|
+
alwaysRender?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Tooltip({
|
|
40
|
+
content,
|
|
41
|
+
children,
|
|
42
|
+
delay = 120,
|
|
43
|
+
placement = "top",
|
|
44
|
+
alwaysRender = false,
|
|
45
|
+
}: TooltipProps) {
|
|
46
|
+
const triggerRef = useRef<HTMLElement | null>(null);
|
|
47
|
+
const [open, setOpen] = useState(false);
|
|
48
|
+
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
|
|
49
|
+
const timerRef = useRef<number | null>(null);
|
|
50
|
+
|
|
51
|
+
const cancelTimer = useCallback(() => {
|
|
52
|
+
if (timerRef.current !== null) {
|
|
53
|
+
window.clearTimeout(timerRef.current);
|
|
54
|
+
timerRef.current = null;
|
|
55
|
+
}
|
|
56
|
+
}, []);
|
|
57
|
+
|
|
58
|
+
const computePos = useCallback(() => {
|
|
59
|
+
const el = triggerRef.current;
|
|
60
|
+
if (!el) return;
|
|
61
|
+
const r = el.getBoundingClientRect();
|
|
62
|
+
setPos({
|
|
63
|
+
x: r.left + r.width / 2,
|
|
64
|
+
y: placement === "top" ? r.top : r.bottom,
|
|
65
|
+
});
|
|
66
|
+
}, [placement]);
|
|
67
|
+
|
|
68
|
+
const show = useCallback(() => {
|
|
69
|
+
cancelTimer();
|
|
70
|
+
timerRef.current = window.setTimeout(() => {
|
|
71
|
+
computePos();
|
|
72
|
+
setOpen(true);
|
|
73
|
+
timerRef.current = null;
|
|
74
|
+
}, delay);
|
|
75
|
+
}, [cancelTimer, computePos, delay]);
|
|
76
|
+
|
|
77
|
+
const hide = useCallback(() => {
|
|
78
|
+
cancelTimer();
|
|
79
|
+
setOpen(false);
|
|
80
|
+
}, [cancelTimer]);
|
|
81
|
+
|
|
82
|
+
useEffect(() => {
|
|
83
|
+
if (!open) return;
|
|
84
|
+
const onScrollResize = () => computePos();
|
|
85
|
+
window.addEventListener("scroll", onScrollResize, { passive: true, capture: true });
|
|
86
|
+
window.addEventListener("resize", onScrollResize);
|
|
87
|
+
return () => {
|
|
88
|
+
window.removeEventListener("scroll", onScrollResize, { capture: true });
|
|
89
|
+
window.removeEventListener("resize", onScrollResize);
|
|
90
|
+
};
|
|
91
|
+
}, [open, computePos]);
|
|
92
|
+
|
|
93
|
+
useEffect(() => cancelTimer, [cancelTimer]);
|
|
94
|
+
|
|
95
|
+
if (!isValidElement(children)) return children;
|
|
96
|
+
if (!content && !alwaysRender) return children;
|
|
97
|
+
|
|
98
|
+
const child = children;
|
|
99
|
+
// cloneElement's `Partial<P>` signature isn't compatible with the
|
|
100
|
+
// `T | undefined` event-handler types in HTMLAttributes once
|
|
101
|
+
// `exactOptionalPropertyTypes: true` is on. The runtime behaviour is fine —
|
|
102
|
+
// React happily accepts these props on any host element. We narrow via
|
|
103
|
+
// unknown to silence the type system on a known React-types limitation.
|
|
104
|
+
const clonedProps = {
|
|
105
|
+
ref: (el: HTMLElement | null) => {
|
|
106
|
+
triggerRef.current = el;
|
|
107
|
+
const childRef = (child as unknown as { ref?: React.Ref<HTMLElement> }).ref;
|
|
108
|
+
if (typeof childRef === "function") childRef(el);
|
|
109
|
+
else if (childRef && typeof childRef === "object") {
|
|
110
|
+
(childRef as { current: HTMLElement | null }).current = el;
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
onMouseEnter: (e: React.MouseEvent) => {
|
|
114
|
+
child.props.onMouseEnter?.(e);
|
|
115
|
+
show();
|
|
116
|
+
},
|
|
117
|
+
onMouseLeave: (e: React.MouseEvent) => {
|
|
118
|
+
child.props.onMouseLeave?.(e);
|
|
119
|
+
hide();
|
|
120
|
+
},
|
|
121
|
+
onFocus: (e: React.FocusEvent) => {
|
|
122
|
+
child.props.onFocus?.(e);
|
|
123
|
+
show();
|
|
124
|
+
},
|
|
125
|
+
onBlur: (e: React.FocusEvent) => {
|
|
126
|
+
child.props.onBlur?.(e);
|
|
127
|
+
hide();
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
const clonedTrigger = cloneElement(
|
|
131
|
+
child,
|
|
132
|
+
clonedProps as unknown as Parameters<typeof cloneElement>[1],
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
const tooltipStyle: CSSProperties = {
|
|
136
|
+
position: "fixed",
|
|
137
|
+
left: pos?.x ?? -9999,
|
|
138
|
+
top: pos?.y ?? -9999,
|
|
139
|
+
transform:
|
|
140
|
+
placement === "top"
|
|
141
|
+
? "translate(-50%, calc(-100% - 8px))"
|
|
142
|
+
: "translate(-50%, 8px)",
|
|
143
|
+
background: "var(--ck-bg-surface)",
|
|
144
|
+
color: "var(--ck-text-primary)",
|
|
145
|
+
border: "1px solid var(--ck-border-strong)",
|
|
146
|
+
borderRadius: "var(--ck-radius-sm)",
|
|
147
|
+
boxShadow: "var(--ck-shadow-2)",
|
|
148
|
+
padding: "6px 10px",
|
|
149
|
+
fontSize: 11,
|
|
150
|
+
fontFamily: "var(--ck-font-mono)",
|
|
151
|
+
lineHeight: 1.45,
|
|
152
|
+
letterSpacing: "0.02em",
|
|
153
|
+
pointerEvents: "none",
|
|
154
|
+
zIndex: 9999,
|
|
155
|
+
maxWidth: 320,
|
|
156
|
+
whiteSpace: "nowrap",
|
|
157
|
+
overflow: "hidden",
|
|
158
|
+
textOverflow: "ellipsis",
|
|
159
|
+
opacity: open && pos ? 1 : 0,
|
|
160
|
+
transition: "opacity 120ms ease",
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
return (
|
|
164
|
+
<>
|
|
165
|
+
{clonedTrigger}
|
|
166
|
+
{(open || alwaysRender) &&
|
|
167
|
+
pos &&
|
|
168
|
+
createPortal(<div style={tooltipStyle}>{content}</div>, document.body)}
|
|
169
|
+
</>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export { Button } from "./Button";
|
|
2
|
+
export type { ButtonProps, ButtonVariant } from "./Button";
|
|
3
|
+
export { Card } from "./Card";
|
|
4
|
+
export type { CardProps } from "./Card";
|
|
5
|
+
export { Tag } from "./Tag";
|
|
6
|
+
export type { TagProps, TagTone } from "./Tag";
|
|
7
|
+
export { CountBadge } from "./CountBadge";
|
|
8
|
+
export type { CountBadgeProps } from "./CountBadge";
|
|
9
|
+
export { Kbd } from "./Kbd";
|
|
10
|
+
export type { KbdProps } from "./Kbd";
|
|
11
|
+
export { Avatar } from "./Avatar";
|
|
12
|
+
export type { AvatarProps } from "./Avatar";
|
|
13
|
+
export { Input } from "./Input";
|
|
14
|
+
export type { InputProps } from "./Input";
|
|
15
|
+
export { Textarea } from "./Textarea";
|
|
16
|
+
export type { TextareaProps } from "./Textarea";
|
|
17
|
+
export { Checkbox } from "./Checkbox";
|
|
18
|
+
export type { CheckboxProps } from "./Checkbox";
|
|
19
|
+
export { ToggleSwitch } from "./ToggleSwitch";
|
|
20
|
+
export type { ToggleSwitchProps } from "./ToggleSwitch";
|
|
21
|
+
export { Tooltip } from "./Tooltip";
|
|
22
|
+
export type { TooltipProps } from "./Tooltip";
|
|
23
|
+
export { PageHeader } from "./PageHeader";
|
|
24
|
+
export type { PageHeaderProps } from "./PageHeader";
|