@protolabsai/ui 0.5.0 → 0.6.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.5.0",
3
+ "version": "0.6.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -5,7 +5,7 @@ const meta: Meta<typeof Button> = {
5
5
  title: "Components/Button",
6
6
  component: Button,
7
7
  args: { children: "Breakdowns" },
8
- argTypes: { variant: { control: "inline-radio", options: ["default", "primary"] } },
8
+ argTypes: { variant: { control: "inline-radio", options: ["default", "primary", "ghost", "danger"] } },
9
9
  };
10
10
  export default meta;
11
11
  type Story = StoryObj<typeof Button>;
@@ -13,3 +13,48 @@ type Story = StoryObj<typeof Button>;
13
13
  export const Default: Story = {};
14
14
  export const Primary: Story = { args: { variant: "primary", children: "Get started" } };
15
15
  export const Disabled: Story = { args: { disabled: true, children: "Disabled" } };
16
+
17
+ const Sq = () => (
18
+ <svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
19
+ <path d="M8 3v10M3 8h10" strokeLinecap="round" />
20
+ </svg>
21
+ );
22
+
23
+ export const Variants: Story = {
24
+ render: () => (
25
+ <div style={{ display: "flex", gap: 8, flexWrap: "wrap", alignItems: "center" }}>
26
+ <Button>default</Button>
27
+ <Button variant="primary">primary</Button>
28
+ <Button variant="ghost">ghost</Button>
29
+ <Button variant="danger">danger</Button>
30
+ </div>
31
+ ),
32
+ };
33
+
34
+ export const Sizes: Story = {
35
+ render: () => (
36
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
37
+ <Button size="sm">small</Button>
38
+ <Button>medium</Button>
39
+ <Button size="sm" variant="primary">
40
+ small primary
41
+ </Button>
42
+ </div>
43
+ ),
44
+ };
45
+
46
+ export const IconOnly: Story = {
47
+ render: () => (
48
+ <div style={{ display: "flex", gap: 8, alignItems: "center" }}>
49
+ <Button icon aria-label="Add">
50
+ <Sq />
51
+ </Button>
52
+ <Button icon variant="ghost" aria-label="Add">
53
+ <Sq />
54
+ </Button>
55
+ <Button icon size="sm" aria-label="Add">
56
+ <Sq />
57
+ </Button>
58
+ </div>
59
+ ),
60
+ };
@@ -0,0 +1,37 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Skeleton, SkeletonGroup } from "./index";
3
+
4
+ const meta: Meta = { title: "Components/Skeleton" };
5
+ export default meta;
6
+ type Story = StoryObj;
7
+
8
+ export const Bars: Story = {
9
+ render: () => (
10
+ <div style={{ display: "grid", gap: 12, maxWidth: 360 }}>
11
+ <Skeleton />
12
+ <Skeleton width={200} />
13
+ <Skeleton width={120} height={28} />
14
+ </div>
15
+ ),
16
+ };
17
+
18
+ export const TextLines: Story = {
19
+ render: () => (
20
+ <div style={{ maxWidth: 360 }}>
21
+ <Skeleton lines={4} />
22
+ </div>
23
+ ),
24
+ };
25
+
26
+ export const PanelSkeleton: Story = {
27
+ render: () => (
28
+ <SkeletonGroup style={{ maxWidth: 420, padding: 16, border: "1px solid var(--pl-color-border)", borderRadius: 4 }}>
29
+ <Skeleton width={140} height={16} />
30
+ <Skeleton lines={3} />
31
+ <div style={{ display: "flex", gap: 8 }}>
32
+ <Skeleton width={80} height={28} />
33
+ <Skeleton width={80} height={28} />
34
+ </div>
35
+ </SkeletonGroup>
36
+ ),
37
+ };
package/src/index.tsx CHANGED
@@ -32,11 +32,26 @@ import "./styles.css";
32
32
  const cx = (...parts: Array<string | false | undefined>) => parts.filter(Boolean).join(" ");
33
33
 
34
34
  export type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
35
- /** "primary" reads as a stronger border, not a fill (brand restraint). */
36
- variant?: "default" | "primary";
35
+ /** "primary"/"danger" read as a stronger border, not a fill (brand restraint);
36
+ * "ghost" is transparent until hover. */
37
+ variant?: "default" | "primary" | "ghost" | "danger";
38
+ size?: "sm" | "md";
39
+ /** Icon-only: square, centered glyph. Pass `aria-label` for a11y. */
40
+ icon?: boolean;
37
41
  };
38
- export function Button({ variant = "default", className, ...rest }: ButtonProps) {
39
- return <button className={cx("pl-btn", variant === "primary" && "pl-btn--primary", className)} {...rest} />;
42
+ export function Button({ variant = "default", size = "md", icon, className, ...rest }: ButtonProps) {
43
+ return (
44
+ <button
45
+ className={cx(
46
+ "pl-btn",
47
+ variant !== "default" && `pl-btn--${variant}`,
48
+ size === "sm" && "pl-btn--sm",
49
+ icon && "pl-btn--icon",
50
+ className,
51
+ )}
52
+ {...rest}
53
+ />
54
+ );
40
55
  }
41
56
 
42
57
  export type Status = "neutral" | "success" | "warning" | "error" | "info";
@@ -515,11 +530,7 @@ export function ConfirmDialog({
515
530
  footer={
516
531
  <>
517
532
  <Button onClick={onClose}>{cancelLabel}</Button>
518
- <Button
519
- variant="primary"
520
- className={cx(destructive && "pl-btn--danger")}
521
- onClick={() => onConfirm?.()}
522
- >
533
+ <Button variant={destructive ? "danger" : "primary"} onClick={() => onConfirm?.()}>
523
534
  {confirmLabel}
524
535
  </Button>
525
536
  </>
@@ -745,9 +756,16 @@ export function Spinner({ size = 16, className }: { size?: number; className?: s
745
756
  );
746
757
  }
747
758
 
748
- /** Scroll container with brand-styled thin scrollbars. */
749
- export function ScrollArea({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
750
- return <div className={cx("pl-scroll", className)} {...rest} />;
759
+ /** Scroll container with brand-styled thin scrollbars. Carries `min-height:0`
760
+ * (so it scrolls inside flex/grid parents) + overscroll containment. Pass
761
+ * `ariaLabel` to make it a keyboard-focusable, labelled scroll region. */
762
+ export function ScrollArea({
763
+ ariaLabel,
764
+ className,
765
+ ...rest
766
+ }: HTMLAttributes<HTMLDivElement> & { ariaLabel?: string }) {
767
+ const a11y = ariaLabel != null ? { role: "region", "aria-label": ariaLabel, tabIndex: 0 } : {};
768
+ return <div className={cx("pl-scroll", className)} {...a11y} {...rest} />;
751
769
  }
752
770
 
753
771
  // ── Form controls (compose with Field, or use standalone) ────────────────────
@@ -964,3 +982,43 @@ export function MenuSub({
964
982
  </RDropdown.Sub>
965
983
  );
966
984
  }
985
+
986
+ // ── Skeleton (loading placeholder) ───────────────────────────────────────────
987
+
988
+ /** Shimmering content-shaped loading placeholder. `lines>1` stacks text bars
989
+ * (last one short). Token-driven; static fill under reduced-motion. */
990
+ export function Skeleton({
991
+ width,
992
+ height = 14,
993
+ lines,
994
+ className,
995
+ style,
996
+ ...rest
997
+ }: HTMLAttributes<HTMLDivElement> & {
998
+ width?: number | string;
999
+ height?: number | string;
1000
+ /** Stack N text-line bars instead of a single bar. */
1001
+ lines?: number;
1002
+ }) {
1003
+ if (lines != null && lines > 1) {
1004
+ return (
1005
+ <div className={cx("pl-skel-lines", className)} style={style} {...rest}>
1006
+ {Array.from({ length: lines }, (_, i) => (
1007
+ <div
1008
+ key={i}
1009
+ className="pl-skel"
1010
+ style={{ height, width: i === lines - 1 ? "65%" : (width ?? "100%") }}
1011
+ />
1012
+ ))}
1013
+ </div>
1014
+ );
1015
+ }
1016
+ return (
1017
+ <div className={cx("pl-skel", className)} style={{ width: width ?? "100%", height, ...style }} {...rest} />
1018
+ );
1019
+ }
1020
+
1021
+ /** Optional wrapper to group related skeletons (shared layout gap). */
1022
+ export function SkeletonGroup({ className, ...rest }: HTMLAttributes<HTMLDivElement>) {
1023
+ return <div className={cx("pl-skel-group", className)} {...rest} />;
1024
+ }
package/src/styles.css CHANGED
@@ -734,6 +734,33 @@ a.pl-row:hover {
734
734
  border-color: var(--pl-color-status-error);
735
735
  color: var(--pl-color-bg);
736
736
  }
737
+ /* Ghost — transparent until hover, for toolbars / inline actions. */
738
+ .pl-btn--ghost {
739
+ border-color: transparent;
740
+ }
741
+ .pl-btn--ghost:hover {
742
+ border-color: var(--pl-color-border-strong);
743
+ background: var(--pl-color-bg-hover);
744
+ }
745
+ .pl-btn--sm {
746
+ padding: 0.3rem 0.6rem;
747
+ font-size: 12px;
748
+ }
749
+ /* Icon-only — square, centered glyph. */
750
+ .pl-btn--icon {
751
+ padding: 0;
752
+ width: 30px;
753
+ height: 30px;
754
+ justify-content: center;
755
+ }
756
+ .pl-btn--icon.pl-btn--sm {
757
+ width: 26px;
758
+ height: 26px;
759
+ }
760
+ .pl-btn--icon svg {
761
+ width: 16px;
762
+ height: 16px;
763
+ }
737
764
 
738
765
  /* ── Drawer (slide-in sheet) ─────────────────────────────────────────────────── */
739
766
  .pl-drawer {
@@ -1007,9 +1034,18 @@ a.pl-row:hover {
1007
1034
  /* ── ScrollArea (thin brand scrollbars) ──────────────────────────────────────── */
1008
1035
  .pl-scroll {
1009
1036
  overflow: auto;
1037
+ /* min-height:0 lets the region actually scroll inside a flex/grid parent
1038
+ (the classic min-size trap) instead of overflowing it. */
1039
+ min-height: 0;
1040
+ overscroll-behavior: contain;
1041
+ scrollbar-gutter: stable;
1010
1042
  scrollbar-width: thin;
1011
1043
  scrollbar-color: var(--pl-color-border-strong) transparent;
1012
1044
  }
1045
+ .pl-scroll:focus-visible {
1046
+ outline: 1px solid var(--pl-color-fg);
1047
+ outline-offset: -2px;
1048
+ }
1013
1049
  .pl-scroll::-webkit-scrollbar {
1014
1050
  width: 8px;
1015
1051
  height: 8px;
@@ -1239,6 +1275,37 @@ a.pl-row:hover {
1239
1275
  color: var(--pl-color-fg-muted);
1240
1276
  }
1241
1277
 
1278
+ /* ── Skeleton (loading placeholder) ──────────────────────────────────────────── */
1279
+ .pl-skel {
1280
+ display: block;
1281
+ border-radius: var(--pl-radius);
1282
+ background-color: var(--pl-color-bg-subtle);
1283
+ background-image: linear-gradient(
1284
+ 90deg,
1285
+ var(--pl-color-bg-subtle) 25%,
1286
+ var(--pl-color-bg-hover) 50%,
1287
+ var(--pl-color-bg-subtle) 75%
1288
+ );
1289
+ background-size: 200% 100%;
1290
+ animation: pl-skel-shimmer 1.4s var(--pl-motion-ease-in-out) infinite;
1291
+ }
1292
+ @keyframes pl-skel-shimmer {
1293
+ from {
1294
+ background-position: 200% 0;
1295
+ }
1296
+ to {
1297
+ background-position: -200% 0;
1298
+ }
1299
+ }
1300
+ .pl-skel-lines {
1301
+ display: grid;
1302
+ gap: 8px;
1303
+ }
1304
+ .pl-skel-group {
1305
+ display: grid;
1306
+ gap: var(--pl-space-3);
1307
+ }
1308
+
1242
1309
  /* Respect reduced-motion for every animation this package introduces. */
1243
1310
  @media (prefers-reduced-motion: reduce) {
1244
1311
  .pl-drawer,
@@ -1247,4 +1314,9 @@ a.pl-row:hover {
1247
1314
  .pl-spinner {
1248
1315
  animation: none;
1249
1316
  }
1317
+ /* Skeletons go static (solid fill, no shimmer). */
1318
+ .pl-skel {
1319
+ animation: none;
1320
+ background-image: none;
1321
+ }
1250
1322
  }