@protolabsai/ui 0.6.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +10 -2
- package/src/AppShell.full.stories.tsx +130 -0
- package/src/AppShell.stories.tsx +3 -2
- package/src/Badge.stories.tsx +2 -2
- package/src/Blog.stories.tsx +4 -2
- package/src/Button.stories.tsx +2 -2
- package/src/Content.stories.tsx +4 -2
- package/src/Data.stories.tsx +2 -1
- package/src/Forms.stories.tsx +1 -1
- package/src/Introduction.stories.tsx +3 -13
- package/src/Menu.stories.tsx +2 -1
- package/src/Overlays.stories.tsx +3 -10
- package/src/Primitives.stories.tsx +2 -2
- package/src/Process.stories.tsx +3 -2
- package/src/Row.stories.tsx +2 -2
- package/src/Skeleton.stories.tsx +2 -2
- package/src/Surface.stories.tsx +3 -2
- package/src/app-shell.tsx +309 -0
- package/src/data.tsx +126 -0
- package/src/forms.tsx +109 -0
- package/src/internal.ts +11 -0
- package/src/layout.tsx +57 -0
- package/src/marketing.tsx +95 -0
- package/src/menu.tsx +141 -0
- package/src/navigation.tsx +94 -0
- package/src/overlays.tsx +299 -0
- package/src/primitives.tsx +90 -0
- package/src/styles/app-shell.css +212 -0
- package/src/styles/data.css +185 -0
- package/src/styles/forms.css +193 -0
- package/src/styles/layout.css +98 -0
- package/src/styles/marketing.css +249 -0
- package/src/styles/menu.css +90 -0
- package/src/styles/navigation.css +173 -0
- package/src/styles/overlays.css +295 -0
- package/src/styles/primitives.css +219 -0
- package/src/styles.css +12 -1322
- package/src/index.tsx +0 -1024
package/src/data.tsx
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
HTMLAttributes,
|
|
3
|
+
ReactNode,
|
|
4
|
+
TableHTMLAttributes,
|
|
5
|
+
TdHTMLAttributes,
|
|
6
|
+
ThHTMLAttributes,
|
|
7
|
+
} from "react";
|
|
8
|
+
import { cx } from "./internal";
|
|
9
|
+
import type { Status } from "./internal";
|
|
10
|
+
|
|
11
|
+
/** Dense data table on the 4px grid. Compose with THead/TBody/Tr/Th/Td. */
|
|
12
|
+
export function Table({ className, ...rest }: TableHTMLAttributes<HTMLTableElement>) {
|
|
13
|
+
return <table className={cx("pl-table", className)} {...rest} />;
|
|
14
|
+
}
|
|
15
|
+
export function THead(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
16
|
+
return <thead {...props} />;
|
|
17
|
+
}
|
|
18
|
+
export function TBody(props: HTMLAttributes<HTMLTableSectionElement>) {
|
|
19
|
+
return <tbody {...props} />;
|
|
20
|
+
}
|
|
21
|
+
/** Table row. `selected` highlights; an `onClick` makes it hover-interactive. */
|
|
22
|
+
export function Tr({
|
|
23
|
+
selected,
|
|
24
|
+
className,
|
|
25
|
+
...rest
|
|
26
|
+
}: HTMLAttributes<HTMLTableRowElement> & { selected?: boolean }) {
|
|
27
|
+
return (
|
|
28
|
+
<tr
|
|
29
|
+
className={cx(selected && "pl-tr--selected", rest.onClick && "pl-tr--interactive", className)}
|
|
30
|
+
{...rest}
|
|
31
|
+
/>
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
export function Th({ className, ...rest }: ThHTMLAttributes<HTMLTableCellElement>) {
|
|
35
|
+
return <th className={className} {...rest} />;
|
|
36
|
+
}
|
|
37
|
+
export function Td({ className, ...rest }: TdHTMLAttributes<HTMLTableCellElement>) {
|
|
38
|
+
return <td className={className} {...rest} />;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Live/health indicator. `pulse` breathes on the 2s status cadence. */
|
|
42
|
+
export function StatusDot({
|
|
43
|
+
status = "neutral",
|
|
44
|
+
pulse,
|
|
45
|
+
label,
|
|
46
|
+
}: {
|
|
47
|
+
status?: Status;
|
|
48
|
+
pulse?: boolean;
|
|
49
|
+
label?: ReactNode;
|
|
50
|
+
}) {
|
|
51
|
+
const dot = (
|
|
52
|
+
<span
|
|
53
|
+
className={cx("pl-dot", status !== "neutral" && `pl-dot--${status}`, pulse && "pl-dot--pulse")}
|
|
54
|
+
aria-hidden
|
|
55
|
+
/>
|
|
56
|
+
);
|
|
57
|
+
if (label == null) return dot;
|
|
58
|
+
return (
|
|
59
|
+
<span className="pl-dot-row">
|
|
60
|
+
{dot}
|
|
61
|
+
<span className="pl-dot-row__label">{label}</span>
|
|
62
|
+
</span>
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Indeterminate spinner (1s linear, brand-restrained). */
|
|
67
|
+
export function Spinner({ size = 16, className }: { size?: number; className?: string }) {
|
|
68
|
+
return (
|
|
69
|
+
<span
|
|
70
|
+
className={cx("pl-spinner", className)}
|
|
71
|
+
style={{ width: size, height: size }}
|
|
72
|
+
role="status"
|
|
73
|
+
aria-label="Loading"
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
|
|
79
|
+
* (so it scrolls inside flex/grid parents) + overscroll containment. Pass
|
|
80
|
+
* `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
|
|
81
|
+
export function ScrollArea({
|
|
82
|
+
ariaLabel,
|
|
83
|
+
className,
|
|
84
|
+
...rest
|
|
85
|
+
}: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
|
|
86
|
+
const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
|
|
87
|
+
return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
|
|
91
|
+
* (last one short). Token-driven; static fill under reduced-motion. */
|
|
92
|
+
export function Skeleton({
|
|
93
|
+
width,
|
|
94
|
+
height = 14,
|
|
95
|
+
lines,
|
|
96
|
+
className,
|
|
97
|
+
style,
|
|
98
|
+
...rest
|
|
99
|
+
}: HTMLAttributes<HTMLDivElement> & {
|
|
100
|
+
width?: number | string;
|
|
101
|
+
height?: number | string;
|
|
102
|
+
/** Stack N text-line bars instead of a single bar. */
|
|
103
|
+
lines?: number;
|
|
104
|
+
}) {
|
|
105
|
+
if (lines != null && lines > 1) {
|
|
106
|
+
return (
|
|
107
|
+
<div className={cx("pl-skel-lines", className)} style={style} {...rest}>
|
|
108
|
+
{Array.from({ length: lines }, (_, i) => (
|
|
109
|
+
<div
|
|
110
|
+
key={i}
|
|
111
|
+
className="pl-skel"
|
|
112
|
+
style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
|
|
113
|
+
/>
|
|
114
|
+
))}
|
|
115
|
+
</div>
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
return (
|
|
119
|
+
<div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Optional wrapper to group related skeletons (shared layout gap). */
|
|
124
|
+
export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
125
|
+
return <div className={cx("pl-skel-group", className)} {...rest} />;
|
|
126
|
+
}
|
package/src/forms.tsx
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import type { InputHTMLAttributes, ReactNode, SelectHTMLAttributes, TextareaHTMLAttributes } from "react";
|
|
2
|
+
import { cx } from "./internal";
|
|
3
|
+
|
|
4
|
+
/** A labeled input/textarea bound to a string value (form fields, editors). */
|
|
5
|
+
export function Field({
|
|
6
|
+
label,
|
|
7
|
+
value,
|
|
8
|
+
multiline,
|
|
9
|
+
readOnly,
|
|
10
|
+
placeholder,
|
|
11
|
+
onValueChange,
|
|
12
|
+
className,
|
|
13
|
+
}: {
|
|
14
|
+
label: ReactNode;
|
|
15
|
+
value?: string;
|
|
16
|
+
multiline?: boolean;
|
|
17
|
+
readOnly?: boolean;
|
|
18
|
+
placeholder?: string;
|
|
19
|
+
onValueChange?: (value: string) => void;
|
|
20
|
+
className?: string;
|
|
21
|
+
}) {
|
|
22
|
+
const shared = {
|
|
23
|
+
className: "pl-field__input",
|
|
24
|
+
value,
|
|
25
|
+
readOnly,
|
|
26
|
+
placeholder,
|
|
27
|
+
onChange: (e: { target: { value: string } }) => onValueChange?.(e.target.value),
|
|
28
|
+
};
|
|
29
|
+
return (
|
|
30
|
+
<label className={cx("pl-field", className)}>
|
|
31
|
+
<span className="pl-field__label">{label}</span>
|
|
32
|
+
{multiline ? <textarea {...shared} /> : <input {...shared} />}
|
|
33
|
+
</label>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
|
38
|
+
return <input className={cx("pl-input", className)} {...rest} />;
|
|
39
|
+
}
|
|
40
|
+
export function Textarea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
|
41
|
+
return <textarea className={cx("pl-input", "pl-textarea", className)} {...rest} />;
|
|
42
|
+
}
|
|
43
|
+
export function Select({ className, children, ...rest }: SelectHTMLAttributes<HTMLSelectElement>) {
|
|
44
|
+
return (
|
|
45
|
+
<select className={cx("pl-input", "pl-select", className)} {...rest}>
|
|
46
|
+
{children}
|
|
47
|
+
</select>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Toggle switch. Controlled via `checked` / `onCheckedChange`. */
|
|
52
|
+
export function Switch({
|
|
53
|
+
checked,
|
|
54
|
+
onCheckedChange,
|
|
55
|
+
disabled,
|
|
56
|
+
label,
|
|
57
|
+
className,
|
|
58
|
+
}: {
|
|
59
|
+
checked?: boolean;
|
|
60
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
label?: ReactNode;
|
|
63
|
+
className?: string;
|
|
64
|
+
}) {
|
|
65
|
+
return (
|
|
66
|
+
<label className={cx("pl-switch", disabled && "pl-switch--disabled", className)}>
|
|
67
|
+
<input
|
|
68
|
+
type="checkbox"
|
|
69
|
+
className="pl-switch__input"
|
|
70
|
+
checked={checked}
|
|
71
|
+
disabled={disabled}
|
|
72
|
+
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
|
73
|
+
/>
|
|
74
|
+
<span className="pl-switch__track" aria-hidden>
|
|
75
|
+
<span className="pl-switch__thumb" />
|
|
76
|
+
</span>
|
|
77
|
+
{label != null && <span className="pl-switch__label">{label}</span>}
|
|
78
|
+
</label>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Checkbox. Controlled via `checked` / `onCheckedChange`. */
|
|
83
|
+
export function Checkbox({
|
|
84
|
+
checked,
|
|
85
|
+
onCheckedChange,
|
|
86
|
+
disabled,
|
|
87
|
+
label,
|
|
88
|
+
className,
|
|
89
|
+
}: {
|
|
90
|
+
checked?: boolean;
|
|
91
|
+
onCheckedChange?: (checked: boolean) => void;
|
|
92
|
+
disabled?: boolean;
|
|
93
|
+
label?: ReactNode;
|
|
94
|
+
className?: string;
|
|
95
|
+
}) {
|
|
96
|
+
return (
|
|
97
|
+
<label className={cx("pl-checkbox", disabled && "pl-checkbox--disabled", className)}>
|
|
98
|
+
<input
|
|
99
|
+
type="checkbox"
|
|
100
|
+
className="pl-checkbox__input"
|
|
101
|
+
checked={checked}
|
|
102
|
+
disabled={disabled}
|
|
103
|
+
onChange={(e) => onCheckedChange?.(e.target.checked)}
|
|
104
|
+
/>
|
|
105
|
+
<span className="pl-checkbox__box" aria-hidden />
|
|
106
|
+
{label != null && <span className="pl-checkbox__label">{label}</span>}
|
|
107
|
+
</label>
|
|
108
|
+
);
|
|
109
|
+
}
|
package/src/internal.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @protolabsai/ui — package internals. NOT part of the public export surface
|
|
3
|
+
* (no `exports` map entry), so these can't be imported from outside the package.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/** Join truthy class parts into a className string. */
|
|
7
|
+
export const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
|
|
8
|
+
|
|
9
|
+
/** Shared status tone — Badge, Callout, StatusDot, Toast, etc. Re-exported
|
|
10
|
+
* publicly from "@protolabsai/ui/primitives". */
|
|
11
|
+
export type Status = "neutral" | "success" | "warning" | "error" | "info";
|
package/src/layout.tsx
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cx } from "./internal";
|
|
3
|
+
|
|
4
|
+
export function Stat({ value, label }: { value: ReactNode; label: ReactNode }) {
|
|
5
|
+
return (
|
|
6
|
+
<div>
|
|
7
|
+
<div className="pl-stat__num">{value}</div>
|
|
8
|
+
<div className="pl-stat__label">{label}</div>
|
|
9
|
+
</div>
|
|
10
|
+
);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function Container({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
14
|
+
return <div className={cx("pl-container", className)} {...rest} />;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function Section({ className, ...rest }: HTMLAttributes<HTMLElement>) {
|
|
18
|
+
return <section className={cx("pl-section", className)} {...rest} />;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Stats grid — wrap Stat children. Two columns, four at ≥640px. */
|
|
22
|
+
export function Stats({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
23
|
+
return <div className={cx("pl-stats", className)} {...rest} />;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export type RowProps = {
|
|
27
|
+
/** Left mono label / layer. */
|
|
28
|
+
label: string;
|
|
29
|
+
/** Optional mono name above the description. */
|
|
30
|
+
name?: ReactNode;
|
|
31
|
+
desc: ReactNode;
|
|
32
|
+
/** When present, the row widens to label | body | status. */
|
|
33
|
+
status?: ReactNode;
|
|
34
|
+
/** Renders as a link when set. */
|
|
35
|
+
href?: string;
|
|
36
|
+
external?: boolean;
|
|
37
|
+
};
|
|
38
|
+
export function Row({ label, name, desc, status, href, external }: RowProps) {
|
|
39
|
+
const cls = cx("pl-row", status != null && "pl-row--wide");
|
|
40
|
+
const inner = (
|
|
41
|
+
<>
|
|
42
|
+
<span className="pl-row__label">{label}</span>
|
|
43
|
+
<span>
|
|
44
|
+
{name != null && <div className="pl-row__name">{name}</div>}
|
|
45
|
+
<div className="pl-row__desc">{desc}</div>
|
|
46
|
+
</span>
|
|
47
|
+
{status != null && <span className="pl-row__status">{status}</span>}
|
|
48
|
+
</>
|
|
49
|
+
);
|
|
50
|
+
return href ? (
|
|
51
|
+
<a className={cls} href={href} {...(external ? { target: "_blank", rel: "noreferrer noopener" } : {})}>
|
|
52
|
+
{inner}
|
|
53
|
+
</a>
|
|
54
|
+
) : (
|
|
55
|
+
<div className={cls}>{inner}</div>
|
|
56
|
+
);
|
|
57
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cx } from "./internal";
|
|
3
|
+
|
|
4
|
+
/** The one gradient — the tagline word treatment. Foundation §1 + §13. */
|
|
5
|
+
export function GradientText({ children }: { children: ReactNode }) {
|
|
6
|
+
return <span className="pl-gradient-text">{children}</span>;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** Hero header — put an <h1> + <Lead> + <HeroActions> inside. */
|
|
10
|
+
export function Hero({ className, ...rest }: HTMLAttributes<HTMLElement>) {
|
|
11
|
+
return <header className={cx("pl-hero", className)} {...rest} />;
|
|
12
|
+
}
|
|
13
|
+
/** Button row under the hero lead. */
|
|
14
|
+
export function HeroActions({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
15
|
+
return <div className={cx("pl-hero__cta", className)} {...rest} />;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Large muted intro paragraph (hero size). */
|
|
19
|
+
export function Lead({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
|
20
|
+
return <p className={cx("pl-lead", className)} {...rest} />;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Section heading — self-contained h2 (doesn't rely on a global reset). */
|
|
24
|
+
export function Heading({ className, ...rest }: HTMLAttributes<HTMLHeadingElement>) {
|
|
25
|
+
return <h2 className={cx("pl-heading", className)} {...rest} />;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Muted paragraph that introduces a section (body size). */
|
|
29
|
+
export function SectionIntro({ className, ...rest }: HTMLAttributes<HTMLParagraphElement>) {
|
|
30
|
+
return <p className={cx("pl-section-intro", className)} {...rest} />;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Numbered process list — wrap Step children. */
|
|
34
|
+
export function Steps({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
35
|
+
return <div className={cx("pl-steps", className)} {...rest} />;
|
|
36
|
+
}
|
|
37
|
+
export function Step({ n, title, children }: { n: ReactNode; title: ReactNode; children: ReactNode }) {
|
|
38
|
+
return (
|
|
39
|
+
<div className="pl-step">
|
|
40
|
+
<div className="pl-step__num">{n}</div>
|
|
41
|
+
<div>
|
|
42
|
+
<div className="pl-step__title">{title}</div>
|
|
43
|
+
<div className="pl-step__body">{children}</div>
|
|
44
|
+
</div>
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Checklist — wrap Check children. The ✓ mark is rendered for you. */
|
|
50
|
+
export function Checks({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
51
|
+
return <div className={cx("pl-checks", className)} {...rest} />;
|
|
52
|
+
}
|
|
53
|
+
export function Check({ children, mark = "✓" }: { children: ReactNode; mark?: ReactNode }) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="pl-check">
|
|
56
|
+
<span className="pl-check__mark" aria-hidden>
|
|
57
|
+
{mark}
|
|
58
|
+
</span>
|
|
59
|
+
<span>{children}</span>
|
|
60
|
+
</div>
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Two-column deliverable cards (left-border, mono title). */
|
|
65
|
+
export function Deliverables({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
66
|
+
return <div className={cx("pl-deliverables", className)} {...rest} />;
|
|
67
|
+
}
|
|
68
|
+
export function Deliverable({ title, children }: { title: ReactNode; children: ReactNode }) {
|
|
69
|
+
return (
|
|
70
|
+
<div className="pl-deliverable">
|
|
71
|
+
<div className="pl-deliverable__title">{title}</div>
|
|
72
|
+
<div className="pl-deliverable__body">{children}</div>
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Blog index list — wrap PostItem children. */
|
|
78
|
+
export function PostList({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
79
|
+
return <div className={cx("pl-post-list", className)} {...rest} />;
|
|
80
|
+
}
|
|
81
|
+
export type PostItemProps = { meta?: ReactNode; title: ReactNode; excerpt?: ReactNode; href: string };
|
|
82
|
+
export function PostItem({ meta, title, excerpt, href }: PostItemProps) {
|
|
83
|
+
return (
|
|
84
|
+
<a className="pl-post-item" href={href}>
|
|
85
|
+
{meta != null && <div className="pl-post-item__meta">{meta}</div>}
|
|
86
|
+
<div className="pl-post-item__title">{title}</div>
|
|
87
|
+
{excerpt != null && <div className="pl-post-item__excerpt">{excerpt}</div>}
|
|
88
|
+
</a>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Long-form rich-text wrapper (blog post body, docs). */
|
|
93
|
+
export function Prose({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
94
|
+
return <div className={cx("pl-prose", className)} {...rest} />;
|
|
95
|
+
}
|
package/src/menu.tsx
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Menu / DropdownMenu — Radix owns keyboard nav, focus management, and
|
|
2
|
+
// collision-aware positioning (the reason this lives in the DS rather than being
|
|
3
|
+
// re-rolled per app). Styling is token-only over --pl-*. Supports a standard
|
|
4
|
+
// click trigger AND imperative open-at-coordinates for right-click / context menus.
|
|
5
|
+
import type { ReactNode } from "react";
|
|
6
|
+
import { forwardRef, useCallback, useImperativeHandle, useState } from "react";
|
|
7
|
+
import * as RDropdown from "@radix-ui/react-dropdown-menu";
|
|
8
|
+
import { cx } from "./internal";
|
|
9
|
+
|
|
10
|
+
export type MenuHandle = {
|
|
11
|
+
/** Open the menu. Pass viewport coords (e.g. from a contextmenu event) to
|
|
12
|
+
* open at a point; omit to open at the default anchor. */
|
|
13
|
+
open: (coords?: { x: number; y: number }) => void;
|
|
14
|
+
close: () => void;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type MenuProps = {
|
|
18
|
+
/** Standard trigger (click to open). Omit and drive via the ref's
|
|
19
|
+
* open({x,y}) for right-click / imperative menus. */
|
|
20
|
+
trigger?: ReactNode;
|
|
21
|
+
children: ReactNode;
|
|
22
|
+
align?: "start" | "center" | "end";
|
|
23
|
+
/** Fires on open/close — e.g. to clear the app's context state on dismiss. */
|
|
24
|
+
onOpenChange?: (open: boolean) => void;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
|
|
28
|
+
{ trigger, children, align = "start", onOpenChange },
|
|
29
|
+
ref,
|
|
30
|
+
) {
|
|
31
|
+
const [open, setOpen] = useState(false);
|
|
32
|
+
const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
|
|
33
|
+
const setOpenState = useCallback(
|
|
34
|
+
(o: boolean) => {
|
|
35
|
+
setOpen(o);
|
|
36
|
+
onOpenChange?.(o);
|
|
37
|
+
},
|
|
38
|
+
[onOpenChange],
|
|
39
|
+
);
|
|
40
|
+
useImperativeHandle(
|
|
41
|
+
ref,
|
|
42
|
+
() => ({
|
|
43
|
+
open: (c) => {
|
|
44
|
+
setCoords(c ?? null);
|
|
45
|
+
setOpenState(true);
|
|
46
|
+
},
|
|
47
|
+
close: () => setOpenState(false),
|
|
48
|
+
}),
|
|
49
|
+
[setOpenState],
|
|
50
|
+
);
|
|
51
|
+
return (
|
|
52
|
+
<RDropdown.Root open={open} onOpenChange={setOpenState} modal={false}>
|
|
53
|
+
{trigger != null ? (
|
|
54
|
+
<RDropdown.Trigger asChild>{trigger}</RDropdown.Trigger>
|
|
55
|
+
) : (
|
|
56
|
+
<RDropdown.Trigger asChild>
|
|
57
|
+
<span
|
|
58
|
+
aria-hidden
|
|
59
|
+
className="pl-menu__anchor"
|
|
60
|
+
style={coords ? { position: "fixed", left: coords.x, top: coords.y } : undefined}
|
|
61
|
+
/>
|
|
62
|
+
</RDropdown.Trigger>
|
|
63
|
+
)}
|
|
64
|
+
<RDropdown.Portal>
|
|
65
|
+
<RDropdown.Content className="pl-menu" align={align} sideOffset={4} collisionPadding={8} loop>
|
|
66
|
+
{children}
|
|
67
|
+
</RDropdown.Content>
|
|
68
|
+
</RDropdown.Portal>
|
|
69
|
+
</RDropdown.Root>
|
|
70
|
+
);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
export function MenuItem({
|
|
74
|
+
icon,
|
|
75
|
+
disabled,
|
|
76
|
+
destructive,
|
|
77
|
+
onSelect,
|
|
78
|
+
children,
|
|
79
|
+
}: {
|
|
80
|
+
icon?: ReactNode;
|
|
81
|
+
disabled?: boolean;
|
|
82
|
+
/** Error-toned (delete, remove, etc.). */
|
|
83
|
+
destructive?: boolean;
|
|
84
|
+
onSelect?: () => void;
|
|
85
|
+
children: ReactNode;
|
|
86
|
+
}) {
|
|
87
|
+
return (
|
|
88
|
+
<RDropdown.Item
|
|
89
|
+
className={cx("pl-menu__item", destructive && "pl-menu__item--destructive")}
|
|
90
|
+
disabled={disabled}
|
|
91
|
+
onSelect={onSelect}
|
|
92
|
+
>
|
|
93
|
+
{icon != null && (
|
|
94
|
+
<span className="pl-menu__icon" aria-hidden>
|
|
95
|
+
{icon}
|
|
96
|
+
</span>
|
|
97
|
+
)}
|
|
98
|
+
<span className="pl-menu__label">{children}</span>
|
|
99
|
+
</RDropdown.Item>
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function MenuSeparator() {
|
|
104
|
+
return <RDropdown.Separator className="pl-menu__sep" />;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function MenuLabel({ children }: { children: ReactNode }) {
|
|
108
|
+
return <RDropdown.Label className="pl-menu__group-label">{children}</RDropdown.Label>;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Nested submenu — put MenuItem/MenuSeparator children inside. */
|
|
112
|
+
export function MenuSub({
|
|
113
|
+
label,
|
|
114
|
+
icon,
|
|
115
|
+
children,
|
|
116
|
+
}: {
|
|
117
|
+
label: ReactNode;
|
|
118
|
+
icon?: ReactNode;
|
|
119
|
+
children: ReactNode;
|
|
120
|
+
}) {
|
|
121
|
+
return (
|
|
122
|
+
<RDropdown.Sub>
|
|
123
|
+
<RDropdown.SubTrigger className="pl-menu__item pl-menu__subtrigger">
|
|
124
|
+
{icon != null && (
|
|
125
|
+
<span className="pl-menu__icon" aria-hidden>
|
|
126
|
+
{icon}
|
|
127
|
+
</span>
|
|
128
|
+
)}
|
|
129
|
+
<span className="pl-menu__label">{label}</span>
|
|
130
|
+
<span className="pl-menu__subarrow" aria-hidden>
|
|
131
|
+
›
|
|
132
|
+
</span>
|
|
133
|
+
</RDropdown.SubTrigger>
|
|
134
|
+
<RDropdown.Portal>
|
|
135
|
+
<RDropdown.SubContent className="pl-menu" sideOffset={2} alignOffset={-4} collisionPadding={8}>
|
|
136
|
+
{children}
|
|
137
|
+
</RDropdown.SubContent>
|
|
138
|
+
</RDropdown.Portal>
|
|
139
|
+
</RDropdown.Sub>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { ButtonHTMLAttributes, HTMLAttributes, ReactNode } from "react";
|
|
2
|
+
import { cx } from "./internal";
|
|
3
|
+
|
|
4
|
+
export type TabItem = {
|
|
5
|
+
id: string;
|
|
6
|
+
label: ReactNode;
|
|
7
|
+
/** Leading icon (e.g. a Lucide glyph). */
|
|
8
|
+
icon?: ReactNode;
|
|
9
|
+
/** Trailing badge / count (e.g. unread inbox count). */
|
|
10
|
+
badge?: ReactNode;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
locked?: boolean;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** A horizontal tab strip with optional icon/badge slots + disabled/locked
|
|
16
|
+
* support (gated workflows). */
|
|
17
|
+
export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
|
|
18
|
+
return (
|
|
19
|
+
<div className="pl-tabs" role="tablist">
|
|
20
|
+
{items.map((t) => (
|
|
21
|
+
<button
|
|
22
|
+
key={t.id}
|
|
23
|
+
role="tab"
|
|
24
|
+
type="button"
|
|
25
|
+
aria-selected={t.id === active}
|
|
26
|
+
className={cx("pl-tab", t.id === active && "pl-tab--active")}
|
|
27
|
+
disabled={t.disabled}
|
|
28
|
+
onClick={() => onSelect(t.id)}
|
|
29
|
+
>
|
|
30
|
+
{t.icon != null && (
|
|
31
|
+
<span className="pl-tab__icon" aria-hidden>
|
|
32
|
+
{t.icon}
|
|
33
|
+
</span>
|
|
34
|
+
)}
|
|
35
|
+
<span className="pl-tab__label">{t.label}</span>
|
|
36
|
+
{t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
|
|
37
|
+
{t.locked ? (
|
|
38
|
+
<span className="pl-tab__lock" aria-hidden>
|
|
39
|
+
🔒
|
|
40
|
+
</span>
|
|
41
|
+
) : null}
|
|
42
|
+
</button>
|
|
43
|
+
))}
|
|
44
|
+
</div>
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A horizontal kanban board. Wrap BoardColumn children. */
|
|
49
|
+
export function Board({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
|
|
50
|
+
return <div className={cx("pl-board", className)} {...rest} />;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function BoardColumn({ title, count, children }: { title: ReactNode; count?: ReactNode; children: ReactNode }) {
|
|
54
|
+
return (
|
|
55
|
+
<div className="pl-board-col">
|
|
56
|
+
<div className="pl-board-col__head">
|
|
57
|
+
<span>{title}</span>
|
|
58
|
+
{count != null ? <span className="pl-board-col__count">{count}</span> : null}
|
|
59
|
+
</div>
|
|
60
|
+
<div className="pl-board-col__body">{children}</div>
|
|
61
|
+
</div>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function BoardCard({ className, ...rest }: ButtonHTMLAttributes<HTMLButtonElement>) {
|
|
66
|
+
return <button type="button" className={cx("pl-board-card", className)} {...rest} />;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Dense operator-console panel header — title + optional kicker eyebrow +
|
|
70
|
+
* right-aligned actions slot. `compact` tightens it for nested/secondary
|
|
71
|
+
* panels. The most-used console composite. */
|
|
72
|
+
export function PanelHeader({
|
|
73
|
+
title,
|
|
74
|
+
kicker,
|
|
75
|
+
actions,
|
|
76
|
+
compact,
|
|
77
|
+
className,
|
|
78
|
+
}: {
|
|
79
|
+
title: ReactNode;
|
|
80
|
+
kicker?: ReactNode;
|
|
81
|
+
actions?: ReactNode;
|
|
82
|
+
compact?: boolean;
|
|
83
|
+
className?: string;
|
|
84
|
+
}) {
|
|
85
|
+
return (
|
|
86
|
+
<div className={cx("pl-panel-header", compact && "pl-panel-header--compact", className)}>
|
|
87
|
+
<div className="pl-panel-header__titles">
|
|
88
|
+
{kicker != null && <div className="pl-panel-header__kicker">{kicker}</div>}
|
|
89
|
+
<h2 className="pl-panel-header__title">{title}</h2>
|
|
90
|
+
</div>
|
|
91
|
+
{actions != null && <div className="pl-panel-header__actions">{actions}</div>}
|
|
92
|
+
</div>
|
|
93
|
+
);
|
|
94
|
+
}
|