@protolabsai/ui 0.4.0 → 0.5.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@protolabsai/ui",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -19,6 +19,7 @@
19
19
  "src"
20
20
  ],
21
21
  "dependencies": {
22
+ "@radix-ui/react-dropdown-menu": "^2.1.17",
22
23
  "@types/react": "^19.0.0",
23
24
  "@types/react-dom": "^19.0.0",
24
25
  "@protolabsai/design": "0.4.0"
@@ -0,0 +1,61 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useState } from "react";
3
+ import { Badge, Button, PanelHeader, Tabs } from "./index";
4
+
5
+ const meta: Meta = { title: "Components/AppShell" };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ const Glyph = () => (
10
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
11
+ <rect x="2.5" y="2.5" width="11" height="11" rx="2" />
12
+ </svg>
13
+ );
14
+
15
+ export const Panel: Story = {
16
+ render: () => (
17
+ <div style={{ display: "grid", gap: 20, maxWidth: 560 }}>
18
+ <div style={{ border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
19
+ <PanelHeader
20
+ kicker="agent · streaming-router"
21
+ title="Activity"
22
+ actions={
23
+ <>
24
+ <Button>Filter</Button>
25
+ <Button variant="primary">New run</Button>
26
+ </>
27
+ }
28
+ />
29
+ <div style={{ padding: 16, color: "var(--pl-color-fg-muted)", fontSize: 13 }}>panel body…</div>
30
+ </div>
31
+
32
+ <div style={{ border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
33
+ <PanelHeader compact title="Inbox" actions={<Badge status="warning">3 unread</Badge>} />
34
+ <div style={{ padding: 12, color: "var(--pl-color-fg-muted)", fontSize: 13 }}>compact, nested panel…</div>
35
+ </div>
36
+ </div>
37
+ ),
38
+ };
39
+
40
+ export const TabsWithSlots: Story = {
41
+ render: () => {
42
+ function Demo() {
43
+ const [active, setActive] = useState("activity");
44
+ return (
45
+ <Tabs
46
+ active={active}
47
+ onSelect={setActive}
48
+ items={[
49
+ { id: "chat", label: "Chat", icon: <Glyph /> },
50
+ { id: "activity", label: "Activity", icon: <Glyph />, badge: 12 },
51
+ { id: "knowledge", label: "Knowledge", icon: <Glyph /> },
52
+ { id: "plugins", label: "Plugins", icon: <Glyph />, badge: "new" },
53
+ { id: "settings", label: "Settings", icon: <Glyph /> },
54
+ { id: "billing", label: "Billing", icon: <Glyph />, locked: true, disabled: true },
55
+ ]}
56
+ />
57
+ );
58
+ }
59
+ return <Demo />;
60
+ },
61
+ };
@@ -0,0 +1,76 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { useRef } from "react";
3
+ import { Button, Menu, MenuHandle, MenuItem, MenuLabel, MenuSeparator, MenuSub } from "./index";
4
+
5
+ const meta: Meta = { title: "Components/Menu" };
6
+ export default meta;
7
+ type Story = StoryObj;
8
+
9
+ // Tiny inline icon so stories stay dependency-free.
10
+ const Dot = () => (
11
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
12
+ <circle cx="8" cy="8" r="3" />
13
+ </svg>
14
+ );
15
+
16
+ const Items = () => (
17
+ <>
18
+ <MenuLabel>Rail surface</MenuLabel>
19
+ <MenuItem icon={<Dot />}>Move up</MenuItem>
20
+ <MenuItem icon={<Dot />}>Move down</MenuItem>
21
+ <MenuSub label="Move to other rail" icon={<Dot />}>
22
+ <MenuItem>Left rail</MenuItem>
23
+ <MenuItem>Right rail</MenuItem>
24
+ </MenuSub>
25
+ <MenuSeparator />
26
+ <MenuItem disabled icon={<Dot />}>
27
+ Pin (coming soon)
28
+ </MenuItem>
29
+ <MenuItem destructive icon={<Dot />}>
30
+ Remove surface
31
+ </MenuItem>
32
+ </>
33
+ );
34
+
35
+ export const Trigger: Story = {
36
+ render: () => (
37
+ <Menu trigger={<Button>Surface actions ▾</Button>}>
38
+ <Items />
39
+ </Menu>
40
+ ),
41
+ };
42
+
43
+ export const RightClickAtCoords: Story = {
44
+ render: () => {
45
+ function Demo() {
46
+ const menu = useRef<MenuHandle>(null);
47
+ return (
48
+ <>
49
+ <div
50
+ onContextMenu={(e) => {
51
+ e.preventDefault();
52
+ menu.current?.open({ x: e.clientX, y: e.clientY });
53
+ }}
54
+ style={{
55
+ height: 180,
56
+ display: "grid",
57
+ placeItems: "center",
58
+ border: "1px dashed var(--pl-color-border-strong)",
59
+ borderRadius: 4,
60
+ color: "var(--pl-color-fg-muted)",
61
+ fontFamily: "var(--pl-font-mono)",
62
+ fontSize: 13,
63
+ userSelect: "none",
64
+ }}
65
+ >
66
+ right-click anywhere in this box
67
+ </div>
68
+ <Menu ref={menu}>
69
+ <Items />
70
+ </Menu>
71
+ </>
72
+ );
73
+ }
74
+ return <Demo />;
75
+ },
76
+ };
package/src/index.tsx CHANGED
@@ -15,7 +15,18 @@ import type {
15
15
  TextareaHTMLAttributes,
16
16
  ThHTMLAttributes,
17
17
  } from "react";
18
- import { createContext, useCallback, useContext, useEffect, useId, useRef, useState } from "react";
18
+ import {
19
+ createContext,
20
+ forwardRef,
21
+ useCallback,
22
+ useContext,
23
+ useEffect,
24
+ useId,
25
+ useImperativeHandle,
26
+ useRef,
27
+ useState,
28
+ } from "react";
29
+ import * as RDropdown from "@radix-ui/react-dropdown-menu";
19
30
  import "./styles.css";
20
31
 
21
32
  const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
@@ -240,9 +251,19 @@ export function TextLink({
240
251
 
241
252
  // ── App primitives (cockpit + future studio apps) ────────────────────────────
242
253
 
243
- export type TabItem = { id: string; label: ReactNode; disabled?: boolean; locked?: boolean };
254
+ export type TabItem = {
255
+ id: string;
256
+ label: ReactNode;
257
+ /** Leading icon (e.g. a Lucide glyph). */
258
+ icon?: ReactNode;
259
+ /** Trailing badge / count (e.g. unread inbox count). */
260
+ badge?: ReactNode;
261
+ disabled?: boolean;
262
+ locked?: boolean;
263
+ };
244
264
 
245
- /** A horizontal tab strip with disabled/locked support (gated workflows). */
265
+ /** A horizontal tab strip with optional icon/badge slots + disabled/locked
266
+ * support (gated workflows). */
246
267
  export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: string; onSelect: (id: string) => void }) {
247
268
  return (
248
269
  <div className="pl-tabs" role="tablist">
@@ -256,7 +277,13 @@ export function Tabs({ items, active, onSelect }: { items: TabItem[]; active: st
256
277
  disabled={t.disabled}
257
278
  onClick={() => onSelect(t.id)}
258
279
  >
259
- {t.label}
280
+ {t.icon != null && (
281
+ <span className="pl-tab__icon" aria-hidden>
282
+ {t.icon}
283
+ </span>
284
+ )}
285
+ <span className="pl-tab__label">{t.label}</span>
286
+ {t.badge != null && <span className="pl-tab__badge">{t.badge}</span>}
260
287
  {t.locked ? (
261
288
  <span className="pl-tab__lock" aria-hidden>
262
289
  🔒
@@ -322,6 +349,33 @@ export function Field({
322
349
  );
323
350
  }
324
351
 
352
+ /** Dense operator-console panel header — title + optional kicker eyebrow +
353
+ * right-aligned actions slot. `compact` tightens it for nested/secondary
354
+ * panels. The most-used console composite. */
355
+ export function PanelHeader({
356
+ title,
357
+ kicker,
358
+ actions,
359
+ compact,
360
+ className,
361
+ }: {
362
+ title: ReactNode;
363
+ kicker?: ReactNode;
364
+ actions?: ReactNode;
365
+ compact?: boolean;
366
+ className?: string;
367
+ }) {
368
+ return (
369
+ <div className={cx("pl-panel-header", compact && "pl-panel-header--compact", className)}>
370
+ <div className="pl-panel-header__titles">
371
+ {kicker != null && <div className="pl-panel-header__kicker">{kicker}</div>}
372
+ <h2 className="pl-panel-header__title">{title}</h2>
373
+ </div>
374
+ {actions != null && <div className="pl-panel-header__actions">{actions}</div>}
375
+ </div>
376
+ );
377
+ }
378
+
325
379
  // ── Overlays & feedback ──────────────────────────────────────────────────────
326
380
 
327
381
  /** Esc-to-close + body scroll-lock while `open`. Shared by Dialog + Drawer. */
@@ -771,3 +825,142 @@ export function Checkbox({
771
825
  </label>
772
826
  );
773
827
  }
828
+
829
+ // ── Menu / DropdownMenu (Radix-backed) ───────────────────────────────────────
830
+ // Radix owns keyboard nav, focus management, and collision-aware positioning —
831
+ // the reason this lives in the DS rather than being re-rolled per app. Styling
832
+ // is token-only over --pl-*. Supports a standard click trigger AND imperative
833
+ // open-at-coordinates for right-click / context menus.
834
+
835
+ export type MenuHandle = {
836
+ /** Open the menu. Pass viewport coords (e.g. from a contextmenu event) to
837
+ * open at a point; omit to open at the default anchor. */
838
+ open: (coords?: { x: number; y: number }) => void;
839
+ close: () => void;
840
+ };
841
+
842
+ export type MenuProps = {
843
+ /** Standard trigger (click to open). Omit and drive via the ref's
844
+ * open({x,y}) for right-click / imperative menus. */
845
+ trigger?: ReactNode;
846
+ children: ReactNode;
847
+ align?: "start" | "center" | "end";
848
+ /** Fires on open/close — e.g. to clear the app's context state on dismiss. */
849
+ onOpenChange?: (open: boolean) => void;
850
+ };
851
+
852
+ export const Menu = forwardRef<MenuHandle, MenuProps>(function Menu(
853
+ { trigger, children, align = "start", onOpenChange },
854
+ ref,
855
+ ) {
856
+ const [open, setOpen] = useState(false);
857
+ const [coords, setCoords] = useState<{ x: number; y: number } | null>(null);
858
+ const setOpenState = useCallback(
859
+ (o: boolean) => {
860
+ setOpen(o);
861
+ onOpenChange?.(o);
862
+ },
863
+ [onOpenChange],
864
+ );
865
+ useImperativeHandle(
866
+ ref,
867
+ () => ({
868
+ open: (c) => {
869
+ setCoords(c ?? null);
870
+ setOpenState(true);
871
+ },
872
+ close: () => setOpenState(false),
873
+ }),
874
+ [setOpenState],
875
+ );
876
+ return (
877
+ <RDropdown.Root open={open} onOpenChange={setOpenState} modal={false}>
878
+ {trigger != null ? (
879
+ <RDropdown.Trigger asChild>{trigger}</RDropdown.Trigger>
880
+ ) : (
881
+ <RDropdown.Trigger asChild>
882
+ <span
883
+ aria-hidden
884
+ className="pl-menu__anchor"
885
+ style={coords ? { position: "fixed", left: coords.x, top: coords.y } : undefined}
886
+ />
887
+ </RDropdown.Trigger>
888
+ )}
889
+ <RDropdown.Portal>
890
+ <RDropdown.Content className="pl-menu" align={align} sideOffset={4} collisionPadding={8} loop>
891
+ {children}
892
+ </RDropdown.Content>
893
+ </RDropdown.Portal>
894
+ </RDropdown.Root>
895
+ );
896
+ });
897
+
898
+ export function MenuItem({
899
+ icon,
900
+ disabled,
901
+ destructive,
902
+ onSelect,
903
+ children,
904
+ }: {
905
+ icon?: ReactNode;
906
+ disabled?: boolean;
907
+ /** Error-toned (delete, remove, etc.). */
908
+ destructive?: boolean;
909
+ onSelect?: () => void;
910
+ children: ReactNode;
911
+ }) {
912
+ return (
913
+ <RDropdown.Item
914
+ className={cx("pl-menu__item", destructive && "pl-menu__item--destructive")}
915
+ disabled={disabled}
916
+ onSelect={onSelect}
917
+ >
918
+ {icon != null && (
919
+ <span className="pl-menu__icon" aria-hidden>
920
+ {icon}
921
+ </span>
922
+ )}
923
+ <span className="pl-menu__label">{children}</span>
924
+ </RDropdown.Item>
925
+ );
926
+ }
927
+
928
+ export function MenuSeparator() {
929
+ return <RDropdown.Separator className="pl-menu__sep" />;
930
+ }
931
+
932
+ export function MenuLabel({ children }: { children: ReactNode }) {
933
+ return <RDropdown.Label className="pl-menu__group-label">{children}</RDropdown.Label>;
934
+ }
935
+
936
+ /** Nested submenu — put MenuItem/MenuSeparator children inside. */
937
+ export function MenuSub({
938
+ label,
939
+ icon,
940
+ children,
941
+ }: {
942
+ label: ReactNode;
943
+ icon?: ReactNode;
944
+ children: ReactNode;
945
+ }) {
946
+ return (
947
+ <RDropdown.Sub>
948
+ <RDropdown.SubTrigger className="pl-menu__item pl-menu__subtrigger">
949
+ {icon != null && (
950
+ <span className="pl-menu__icon" aria-hidden>
951
+ {icon}
952
+ </span>
953
+ )}
954
+ <span className="pl-menu__label">{label}</span>
955
+ <span className="pl-menu__subarrow" aria-hidden>
956
+
957
+ </span>
958
+ </RDropdown.SubTrigger>
959
+ <RDropdown.Portal>
960
+ <RDropdown.SubContent className="pl-menu" sideOffset={2} alignOffset={-4} collisionPadding={8}>
961
+ {children}
962
+ </RDropdown.SubContent>
963
+ </RDropdown.Portal>
964
+ </RDropdown.Sub>
965
+ );
966
+ }
package/src/styles.css CHANGED
@@ -472,6 +472,9 @@ a.pl-row:hover {
472
472
  border-bottom: var(--pl-border-width) solid var(--pl-color-border);
473
473
  }
474
474
  .pl-tab {
475
+ display: inline-flex;
476
+ align-items: center;
477
+ gap: 6px;
475
478
  background: none;
476
479
  border: none;
477
480
  border-bottom: 2px solid transparent;
@@ -482,6 +485,32 @@ a.pl-row:hover {
482
485
  cursor: pointer;
483
486
  border-radius: var(--pl-radius) var(--pl-radius) 0 0;
484
487
  }
488
+ .pl-tab__icon {
489
+ display: inline-flex;
490
+ align-items: center;
491
+ }
492
+ .pl-tab__icon svg {
493
+ width: 15px;
494
+ height: 15px;
495
+ }
496
+ .pl-tab__badge {
497
+ display: inline-flex;
498
+ align-items: center;
499
+ justify-content: center;
500
+ min-width: 16px;
501
+ height: 16px;
502
+ padding: 0 5px;
503
+ font-family: var(--pl-font-mono);
504
+ font-size: 10px;
505
+ line-height: 1;
506
+ color: var(--pl-color-fg-muted);
507
+ background: var(--pl-color-bg-subtle);
508
+ border: var(--pl-border-width) solid var(--pl-color-border);
509
+ border-radius: 999px;
510
+ }
511
+ .pl-tab--active .pl-tab__badge {
512
+ color: var(--pl-color-fg);
513
+ }
485
514
  .pl-tab:hover:not(:disabled) {
486
515
  color: var(--pl-color-fg);
487
516
  }
@@ -579,6 +608,43 @@ a.pl-row:hover {
579
608
  border-color: var(--pl-color-fg);
580
609
  }
581
610
 
611
+ /* ── PanelHeader (console panel header) ──────────────────────────────────────── */
612
+ .pl-panel-header {
613
+ display: flex;
614
+ align-items: center;
615
+ justify-content: space-between;
616
+ gap: var(--pl-space-3);
617
+ padding: var(--pl-space-3) var(--pl-space-4);
618
+ border-bottom: var(--pl-border-width) solid var(--pl-color-border);
619
+ }
620
+ .pl-panel-header__kicker {
621
+ font-family: var(--pl-font-mono);
622
+ font-size: 11px;
623
+ text-transform: uppercase;
624
+ letter-spacing: 0.06em;
625
+ color: var(--pl-color-fg-muted);
626
+ margin-bottom: 2px;
627
+ }
628
+ .pl-panel-header__title {
629
+ margin: 0;
630
+ font-size: 15px;
631
+ font-weight: var(--pl-font-weight-medium);
632
+ line-height: 1.2;
633
+ color: var(--pl-color-fg);
634
+ }
635
+ .pl-panel-header__actions {
636
+ display: flex;
637
+ align-items: center;
638
+ gap: var(--pl-space-2);
639
+ flex-shrink: 0;
640
+ }
641
+ .pl-panel-header--compact {
642
+ padding: var(--pl-space-2) var(--pl-space-3);
643
+ }
644
+ .pl-panel-header--compact .pl-panel-header__title {
645
+ font-size: 13px;
646
+ }
647
+
582
648
  /* ── Overlay scrim (Dialog + Drawer) ─────────────────────────────────────────── */
583
649
  .pl-overlay {
584
650
  position: fixed;
@@ -1097,6 +1163,82 @@ a.pl-row:hover {
1097
1163
  color: var(--pl-color-fg);
1098
1164
  }
1099
1165
 
1166
+ /* ── Menu / DropdownMenu (Radix-backed) ──────────────────────────────────────── */
1167
+ .pl-menu__anchor {
1168
+ width: 0;
1169
+ height: 0;
1170
+ pointer-events: none;
1171
+ }
1172
+ .pl-menu {
1173
+ min-width: 180px;
1174
+ padding: 4px;
1175
+ background: var(--pl-color-bg-raised);
1176
+ border: var(--pl-border-width) solid var(--pl-color-border-strong);
1177
+ border-radius: var(--pl-radius);
1178
+ box-shadow: var(--pl-shadow-popover);
1179
+ z-index: 1200;
1180
+ }
1181
+ .pl-menu__item {
1182
+ display: flex;
1183
+ align-items: center;
1184
+ gap: 8px;
1185
+ padding: 6px 8px;
1186
+ font-size: 13px;
1187
+ color: var(--pl-color-fg);
1188
+ border-radius: calc(var(--pl-radius) - 1px);
1189
+ cursor: pointer;
1190
+ outline: none;
1191
+ user-select: none;
1192
+ }
1193
+ /* Radix sets data-highlighted on the keyboard/pointer-focused item. */
1194
+ .pl-menu__item[data-highlighted] {
1195
+ background: var(--pl-color-bg-hover);
1196
+ }
1197
+ .pl-menu__item[data-disabled] {
1198
+ color: var(--pl-color-fg-muted);
1199
+ opacity: 0.5;
1200
+ pointer-events: none;
1201
+ }
1202
+ .pl-menu__item--destructive {
1203
+ color: var(--pl-color-status-error);
1204
+ }
1205
+ .pl-menu__item--destructive[data-highlighted] {
1206
+ background: color-mix(in oklch, var(--pl-color-status-error) 16%, transparent);
1207
+ }
1208
+ .pl-menu__icon {
1209
+ display: inline-flex;
1210
+ align-items: center;
1211
+ color: var(--pl-color-fg-muted);
1212
+ }
1213
+ .pl-menu__icon svg {
1214
+ width: 15px;
1215
+ height: 15px;
1216
+ }
1217
+ .pl-menu__item--destructive .pl-menu__icon {
1218
+ color: var(--pl-color-status-error);
1219
+ }
1220
+ .pl-menu__label {
1221
+ flex: 1;
1222
+ }
1223
+ .pl-menu__subarrow {
1224
+ color: var(--pl-color-fg-muted);
1225
+ font-size: 14px;
1226
+ line-height: 1;
1227
+ }
1228
+ .pl-menu__sep {
1229
+ height: 1px;
1230
+ margin: 4px 0;
1231
+ background: var(--pl-color-border);
1232
+ }
1233
+ .pl-menu__group-label {
1234
+ padding: 4px 8px;
1235
+ font-family: var(--pl-font-mono);
1236
+ font-size: 10px;
1237
+ text-transform: uppercase;
1238
+ letter-spacing: 0.06em;
1239
+ color: var(--pl-color-fg-muted);
1240
+ }
1241
+
1100
1242
  /* Respect reduced-motion for every animation this package introduces. */
1101
1243
  @media (prefers-reduced-motion: reduce) {
1102
1244
  .pl-drawer,