@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/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
+ }
@@ -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
+ }