@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 +1 -1
- package/src/Button.stories.tsx +46 -1
- package/src/Skeleton.stories.tsx +37 -0
- package/src/index.tsx +70 -12
- package/src/styles.css +72 -0
package/package.json
CHANGED
package/src/Button.stories.tsx
CHANGED
|
@@ -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"
|
|
36
|
-
|
|
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
|
|
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
|
-
|
|
750
|
-
|
|
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
|
}
|