@peerbots/core 0.2.6 → 1.0.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.
Files changed (121) hide show
  1. package/.changeset/config.json +1 -1
  2. package/.github/workflows/storybook.yml +9 -1
  3. package/CHANGELOG.md +94 -0
  4. package/dist/index.d.mts +643 -131
  5. package/dist/index.d.ts +643 -131
  6. package/dist/index.js +7 -6
  7. package/dist/index.js.map +1 -1
  8. package/dist/index.mjs +7 -6
  9. package/dist/index.mjs.map +1 -1
  10. package/package.json +6 -3
  11. package/src/charts/DistributionBarChart.tsx +10 -4
  12. package/src/charts/DistributionHistogram.tsx +13 -5
  13. package/src/charts/LineGraph.stories.tsx +60 -0
  14. package/src/charts/LineGraph.tsx +216 -0
  15. package/src/charts/index.ts +2 -0
  16. package/src/docs/DesignSystem.mdx +241 -25
  17. package/src/styles/theme.css +8 -0
  18. package/src/ui/feedback/Alert.stories.tsx +56 -0
  19. package/src/ui/feedback/Alert.tsx +168 -0
  20. package/src/ui/{Dialog.stories.tsx → feedback/Dialog.stories.tsx} +2 -2
  21. package/src/ui/{Dialog.tsx → feedback/Dialog.tsx} +2 -2
  22. package/src/ui/feedback/EmptyState.stories.tsx +57 -0
  23. package/src/ui/feedback/EmptyState.tsx +123 -0
  24. package/src/ui/feedback/Popover.stories.tsx +130 -0
  25. package/src/ui/feedback/Popover.tsx +135 -0
  26. package/src/ui/feedback/Skeleton.stories.tsx +88 -0
  27. package/src/ui/{Skeleton.tsx → feedback/Skeleton.tsx} +2 -2
  28. package/src/ui/feedback/Spinner.stories.tsx +77 -0
  29. package/src/ui/feedback/Spinner.tsx +62 -0
  30. package/src/ui/{Tooltip.stories.tsx → feedback/Tooltip.stories.tsx} +3 -3
  31. package/src/ui/{Tooltip.tsx → feedback/Tooltip.tsx} +2 -2
  32. package/src/ui/feedback/index.ts +7 -0
  33. package/src/ui/forms/Button.stories.tsx +181 -0
  34. package/src/ui/forms/Button.tsx +323 -0
  35. package/src/ui/{Checkbox.stories.tsx → forms/Checkbox.stories.tsx} +2 -2
  36. package/src/ui/{Checkbox.tsx → forms/Checkbox.tsx} +2 -2
  37. package/src/ui/forms/ChipGroup.stories.tsx +118 -0
  38. package/src/ui/forms/ChipGroup.tsx +239 -0
  39. package/src/ui/{Dropdown.tsx → forms/Dropdown.tsx} +1 -1
  40. package/src/ui/{Field.stories.tsx → forms/Field.stories.tsx} +3 -3
  41. package/src/ui/{Field.tsx → forms/Field.tsx} +1 -1
  42. package/src/ui/{Input.stories.tsx → forms/Input.stories.tsx} +6 -6
  43. package/src/ui/{Input.tsx → forms/Input.tsx} +1 -1
  44. package/src/ui/{Label.stories.tsx → forms/Label.stories.tsx} +2 -2
  45. package/src/ui/{Label.tsx → forms/Label.tsx} +2 -2
  46. package/src/ui/{NumberField.stories.tsx → forms/NumberField.stories.tsx} +2 -2
  47. package/src/ui/{NumberField.tsx → forms/NumberField.tsx} +8 -9
  48. package/src/ui/forms/PeerbotsColorSelector.stories.tsx +49 -0
  49. package/src/ui/forms/PeerbotsColorSelector.tsx +100 -0
  50. package/src/ui/{Select.stories.tsx → forms/Select.stories.tsx} +2 -2
  51. package/src/ui/{Select.tsx → forms/Select.tsx} +5 -3
  52. package/src/ui/{Slider.stories.tsx → forms/Slider.stories.tsx} +6 -4
  53. package/src/ui/{Slider.tsx → forms/Slider.tsx} +7 -3
  54. package/src/ui/forms/SliderWithNumberField.stories.tsx +136 -0
  55. package/src/ui/{SliderWithNumberField.tsx → forms/SliderWithNumberField.tsx} +19 -7
  56. package/src/ui/{Switch.stories.tsx → forms/Switch.stories.tsx} +2 -2
  57. package/src/ui/{Switch.tsx → forms/Switch.tsx} +1 -1
  58. package/src/ui/{TabRadio.stories.tsx → forms/TabRadio.stories.tsx} +3 -3
  59. package/src/ui/{TabRadio.tsx → forms/TabRadio.tsx} +1 -1
  60. package/src/ui/{TextArea.stories.tsx → forms/TextArea.stories.tsx} +5 -3
  61. package/src/ui/{TextArea.tsx → forms/TextArea.tsx} +1 -1
  62. package/src/ui/forms/index.ts +15 -0
  63. package/src/ui/foundations/Anchor.stories.tsx +39 -0
  64. package/src/ui/foundations/Anchor.tsx +96 -0
  65. package/src/ui/foundations/CodeBlock.stories.tsx +86 -0
  66. package/src/ui/foundations/CodeBlock.tsx +148 -0
  67. package/src/ui/foundations/Colors.stories.tsx +80 -0
  68. package/src/ui/{Icon.stories.tsx → foundations/Icon.stories.tsx} +1 -1
  69. package/src/ui/{Icon.tsx → foundations/Icon.tsx} +1 -1
  70. package/src/ui/{IconRegistry.tsx → foundations/IconRegistry.tsx} +261 -57
  71. package/src/ui/foundations/SocialLinks.stories.tsx +38 -0
  72. package/src/ui/foundations/SocialLinks.tsx +117 -0
  73. package/src/ui/foundations/Typography.stories.tsx +58 -0
  74. package/src/ui/foundations/Typography.tsx +163 -0
  75. package/src/ui/foundations/index.ts +6 -0
  76. package/src/ui/index.ts +7 -27
  77. package/src/ui/layout/Accordion.stories.tsx +86 -0
  78. package/src/ui/layout/Accordion.tsx +205 -0
  79. package/src/ui/layout/Card.stories.tsx +151 -0
  80. package/src/ui/layout/Card.tsx +124 -0
  81. package/src/ui/layout/Collapsible.stories.tsx +54 -0
  82. package/src/ui/layout/Collapsible.tsx +54 -0
  83. package/src/ui/{Separator.stories.tsx → layout/Separator.stories.tsx} +2 -2
  84. package/src/ui/{Separator.tsx → layout/Separator.tsx} +1 -1
  85. package/src/ui/{SettingsPanel.stories.tsx → layout/SettingsPanel.stories.tsx} +5 -5
  86. package/src/ui/{SettingsPanel.tsx → layout/SettingsPanel.tsx} +33 -29
  87. package/src/ui/layout/index.ts +5 -0
  88. package/src/ui/media/Avatar.stories.tsx +89 -0
  89. package/src/ui/media/Avatar.tsx +122 -0
  90. package/src/ui/media/Dropzone.stories.tsx +95 -0
  91. package/src/ui/media/Dropzone.tsx +328 -0
  92. package/src/ui/media/FileUpload.stories.tsx +62 -0
  93. package/src/ui/media/FileUpload.tsx +175 -0
  94. package/src/ui/media/index.ts +3 -0
  95. package/src/ui/{AuthFormUI.stories.tsx → patterns/AuthFormUI.stories.tsx} +1 -1
  96. package/src/ui/{AuthFormUI.tsx → patterns/AuthFormUI.tsx} +24 -23
  97. package/src/ui/patterns/CopyButton.stories.tsx +77 -0
  98. package/src/ui/patterns/CopyButton.tsx +93 -0
  99. package/src/ui/patterns/SearchInput.stories.tsx +51 -0
  100. package/src/ui/patterns/SearchInput.tsx +93 -0
  101. package/src/ui/patterns/Stepper.stories.tsx +129 -0
  102. package/src/ui/patterns/Stepper.tsx +295 -0
  103. package/src/ui/{TabSelection.stories.tsx → patterns/TabSelection.stories.tsx} +1 -1
  104. package/src/ui/{TabSelection.tsx → patterns/TabSelection.tsx} +2 -2
  105. package/src/ui/patterns/index.ts +6 -0
  106. package/src/ui/patterns/useClipboard.ts +75 -0
  107. package/src/ui/types.ts +48 -0
  108. package/src/ui/Alert.stories.tsx +0 -41
  109. package/src/ui/Alert.tsx +0 -72
  110. package/src/ui/Anchor.stories.tsx +0 -25
  111. package/src/ui/Anchor.tsx +0 -32
  112. package/src/ui/Button.stories.tsx +0 -108
  113. package/src/ui/Button.tsx +0 -121
  114. package/src/ui/Collapsible.stories.tsx +0 -91
  115. package/src/ui/Collapsible.tsx +0 -52
  116. package/src/ui/Colors.stories.tsx +0 -67
  117. package/src/ui/Popover.tsx +0 -42
  118. package/src/ui/Skeleton.stories.tsx +0 -43
  119. package/src/ui/SliderWithNumberField.stories.tsx +0 -101
  120. package/src/ui/Typography.stories.tsx +0 -87
  121. package/src/ui/Typography.tsx +0 -80
@@ -0,0 +1,117 @@
1
+ import React from "react";
2
+ import { cn } from "../utils";
3
+ import { Icon, IconName } from "./Icon";
4
+
5
+ export interface SocialLinkItem {
6
+ name: string;
7
+ url: string;
8
+ ariaLabel?: string;
9
+ icon?: IconName;
10
+ }
11
+
12
+ export const DEFAULT_PEERBOTS_SOCIAL_LINKS: SocialLinkItem[] = [
13
+ {
14
+ name: "Facebook",
15
+ url: "https://www.facebook.com/peerbots",
16
+ ariaLabel: "Peerbots on Facebook",
17
+ icon: "facebook",
18
+ },
19
+ {
20
+ name: "Instagram",
21
+ url: "https://www.instagram.com/peerbots",
22
+ ariaLabel: "Peerbots on Instagram",
23
+ icon: "instagram",
24
+ },
25
+ {
26
+ name: "LinkedIn",
27
+ url: "https://www.linkedin.com/company/peerbots",
28
+ ariaLabel: "Peerbots on LinkedIn",
29
+ icon: "linkedin",
30
+ },
31
+ {
32
+ name: "Twitter",
33
+ url: "https://twitter.com/peerbots",
34
+ ariaLabel: "Peerbots on Twitter / X",
35
+ icon: "twitter",
36
+ },
37
+ {
38
+ name: "YouTube",
39
+ url: "https://www.youtube.com/@peerbots",
40
+ ariaLabel: "Peerbots on YouTube",
41
+ icon: "youtube",
42
+ },
43
+ {
44
+ name: "BlueSky",
45
+ url: "https://bsky.app/profile/peerbots.bsky.social",
46
+ ariaLabel: "Peerbots on BlueSky",
47
+ icon: "bluesky",
48
+ },
49
+ {
50
+ name: "GitHub",
51
+ url: "https://github.com/peerbots",
52
+ ariaLabel: "Peerbots on GitHub",
53
+ icon: "github",
54
+ },
55
+ ];
56
+
57
+ export interface SocialLinksProps {
58
+ className?: string;
59
+ links?: SocialLinkItem[];
60
+ size?: "sm" | "md" | "lg";
61
+ variant?: "default" | "white" | "teal" | "dark" | "muted";
62
+ }
63
+
64
+ export const SocialLinks: React.FC<SocialLinksProps> = ({
65
+ className,
66
+ links = DEFAULT_PEERBOTS_SOCIAL_LINKS,
67
+ size = "md",
68
+ variant = "default",
69
+ }) => {
70
+ const sizeClasses = {
71
+ sm: "pb:w-4 pb:h-4",
72
+ md: "pb:w-5 pb:h-5",
73
+ lg: "pb:w-6 pb:h-6",
74
+ }[size];
75
+
76
+ const variantClasses = {
77
+ default:
78
+ "pb:text-gray-500 pb:hover:text-peerbots-darkteal pb:hover:bg-peerbots-teal/10",
79
+ white: "pb:text-white/80 pb:hover:text-white pb:hover:bg-white/10",
80
+ teal: "pb:text-peerbots-teal pb:hover:text-peerbots-darkteal pb:hover:bg-peerbots-teal/15",
81
+ dark: "pb:text-gray-900 pb:hover:text-peerbots-teal pb:hover:bg-gray-100",
82
+ muted: "pb:text-gray-400 pb:hover:text-gray-700 pb:hover:bg-gray-100",
83
+ }[variant];
84
+
85
+ const buttonSizeClasses = {
86
+ sm: "pb:p-1.5 pb:rounded-lg",
87
+ md: "pb:p-2 pb:rounded-xl",
88
+ lg: "pb:p-2.5 pb:rounded-2xl",
89
+ }[size];
90
+
91
+ return (
92
+ <div className={cn("pb:flex pb:flex-wrap pb:items-center pb:gap-1.5", className)}>
93
+ {links.map((link) => {
94
+ const iconName = (link.icon ||
95
+ link.name.toLowerCase().replace(/[^a-z0-9]/g, "")) as IconName;
96
+
97
+ return (
98
+ <a
99
+ key={link.name}
100
+ href={link.url}
101
+ target="_blank"
102
+ rel="noopener noreferrer"
103
+ className={cn(
104
+ "pb:inline-flex pb:items-center pb:justify-center pb:transition-all pb:duration-200 pb:cursor-pointer",
105
+ variantClasses,
106
+ buttonSizeClasses,
107
+ )}
108
+ aria-label={link.ariaLabel || `Visit Peerbots on ${link.name}`}
109
+ title={link.name}
110
+ >
111
+ <Icon name={iconName} className={sizeClasses} />
112
+ </a>
113
+ );
114
+ })}
115
+ </div>
116
+ );
117
+ };
@@ -0,0 +1,58 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Heading, Text } from "./Typography";
3
+ import React from "react";
4
+
5
+ const meta: Meta = {
6
+ title: "Foundations/Typography",
7
+ tags: ["autodocs"],
8
+ };
9
+
10
+ export default meta;
11
+
12
+ export const Variations: StoryObj = {
13
+ render: () => (
14
+ <div className="pb:flex pb:flex-col pb:gap-8 pb:p-6 pb:bg-white pb:max-w-4xl">
15
+ <div>
16
+ <span className="pb:text-xs pb:font-bold pb:text-gray-700 pb:uppercase pb:tracking-wider pb:block pb:mb-3">
17
+ Heading Scale & Semantic Tags
18
+ </span>
19
+ <div className="pb:space-y-3">
20
+ <Heading level={1}>Heading 1 (Hero Title)</Heading>
21
+ <Heading level={2}>Heading 2 (Section Title)</Heading>
22
+ <Heading level={3}>Heading 3 (Card Title)</Heading>
23
+ <Heading level={4}>Heading 4 (Panel Title)</Heading>
24
+ <Heading level={5}>Heading 5 (Subsection)</Heading>
25
+ <Heading level={6}>Heading 6 (Micro Title)</Heading>
26
+ </div>
27
+ </div>
28
+
29
+ <div>
30
+ <span className="pb:text-xs pb:font-bold pb:text-gray-700 pb:uppercase pb:tracking-wider pb:block pb:mb-3">
31
+ Heading Brand Colors & Gradient
32
+ </span>
33
+ <div className="pb:space-y-3">
34
+ <Heading level={2} color="teal">Teal Brand Heading</Heading>
35
+ <Heading level={2} color="pink">Pink Brand Heading</Heading>
36
+ <Heading level={2} color="darkblue">Dark Blue Primary Heading</Heading>
37
+ <Heading level={2} color="gradient">Peerbots Hero Gradient Heading</Heading>
38
+ </div>
39
+ </div>
40
+
41
+ <div>
42
+ <span className="pb:text-xs pb:font-bold pb:text-gray-700 pb:uppercase pb:tracking-wider pb:block pb:mb-3">
43
+ Text Scale & Composable Colors
44
+ </span>
45
+ <div className="pb:space-y-3">
46
+ <Text size="lead">Lead paragraph text for introductory overview sections.</Text>
47
+ <Text size="md">Body regular text (md scale, dark slate color for high readability).</Text>
48
+ <Text size="sm" color="muted">Muted caption text (sm scale, subtle gray color).</Text>
49
+ <Text size="xs" color="muted">Extra small microcopy / metadata.</Text>
50
+ <Text size="md" color="teal" weight="bold">Teal bold callout message.</Text>
51
+ <Text size="md" color="pink" weight="semibold">Pink accent notice message.</Text>
52
+ <Text size="sm" color="error">Error validation message.</Text>
53
+ <Text size="sm" color="success">Success confirmation message.</Text>
54
+ </div>
55
+ </div>
56
+ </div>
57
+ ),
58
+ };
@@ -0,0 +1,163 @@
1
+ import React from "react";
2
+ import { cn } from "../utils";
3
+ import { BrandColor, ComponentColor, ComponentSize } from "../types";
4
+
5
+ const headingSizes: Record<number, string> = {
6
+ 1: "pb:text-4xl sm:pb:text-5xl pb:font-bold pb:leading-tight",
7
+ 2: "pb:text-3xl sm:pb:text-4xl pb:font-bold pb:leading-snug",
8
+ 3: "pb:text-2xl sm:pb:text-3xl pb:font-bold pb:leading-snug",
9
+ 4: "pb:text-xl sm:pb:text-2xl pb:font-bold pb:leading-snug",
10
+ 5: "pb:text-lg sm:pb:text-xl pb:font-bold",
11
+ 6: "pb:text-base sm:pb:text-lg pb:font-bold",
12
+ };
13
+
14
+ const headingColorClasses: Record<string, string> = {
15
+ default: "",
16
+ teal: "pb:text-peerbots-darkteal",
17
+ pink: "pb:text-rose-800",
18
+ primary: "pb:text-dark-primary",
19
+ darkteal: "pb:text-peerbots-darkteal",
20
+ darkblue: "pb:text-peerbots-darkblue",
21
+ olive: "pb:text-[#636814]",
22
+ dark: "pb:text-peerbots-dark",
23
+ white: "pb:text-white",
24
+ gradient:
25
+ "pb:bg-clip-text pb:text-transparent pb:bg-gradient-to-r pb:from-peerbots-teal pb:via-peerbots-pink pb:to-peerbots-darkblue",
26
+ };
27
+
28
+ const textColorClasses: Record<string, string> = {
29
+ default: "pb:text-slate-950",
30
+ neutral: "pb:text-gray-800",
31
+ muted: "pb:text-gray-600",
32
+ error: "pb:text-red-700 pb:font-medium",
33
+ success: "pb:text-emerald-800 pb:font-medium",
34
+ teal: "pb:text-peerbots-darkteal",
35
+ pink: "pb:text-rose-800",
36
+ primary: "pb:text-dark-primary",
37
+ darkteal: "pb:text-peerbots-darkteal",
38
+ darkblue: "pb:text-peerbots-darkblue",
39
+ olive: "pb:text-[#636814]",
40
+ dark: "pb:text-peerbots-dark",
41
+ white: "pb:text-white",
42
+ };
43
+
44
+ const textSizeClasses: Record<string, string> = {
45
+ xs: "pb:text-xs pb:leading-normal",
46
+ sm: "pb:text-sm pb:leading-relaxed",
47
+ md: "pb:text-base pb:leading-relaxed",
48
+ lg: "pb:text-lg pb:leading-relaxed",
49
+ xl: "pb:text-xl pb:leading-relaxed",
50
+ lead: "pb:text-lg sm:pb:text-xl pb:leading-relaxed pb:text-slate-700",
51
+ };
52
+
53
+ const weightClasses = {
54
+ normal: "pb:font-normal",
55
+ medium: "pb:font-medium",
56
+ semibold: "pb:font-semibold",
57
+ bold: "pb:font-bold",
58
+ };
59
+
60
+ export interface HeadingProps extends React.HTMLAttributes<HTMLHeadingElement> {
61
+ /** Semantic HTML heading level (h1 - h6) */
62
+ level?: 1 | 2 | 3 | 4 | 5 | 6;
63
+ /** Visual heading scale override (defaults to level) */
64
+ size?: 1 | 2 | 3 | 4 | 5 | 6;
65
+ /** Palette color or gradient text fill */
66
+ color?: BrandColor | "gradient" | "default";
67
+ /** Legacy variant prop (alias for color) */
68
+ variant?: BrandColor | "gradient" | "default";
69
+ /** Font weight */
70
+ weight?: "normal" | "medium" | "semibold" | "bold";
71
+ children?: React.ReactNode;
72
+ }
73
+
74
+ export const Heading = ({
75
+ level = 1,
76
+ size,
77
+ color,
78
+ variant,
79
+ weight,
80
+ className,
81
+ children,
82
+ ...props
83
+ }: HeadingProps) => {
84
+ const visualScale = size !== undefined ? size : level;
85
+ const resolvedColor = color || variant || "default";
86
+
87
+ return React.createElement(
88
+ `h${level}`,
89
+ {
90
+ className: cn(
91
+ headingSizes[visualScale],
92
+ headingColorClasses[resolvedColor] || "",
93
+ weight && weightClasses[weight],
94
+ className,
95
+ ),
96
+ ...props,
97
+ },
98
+ children,
99
+ );
100
+ };
101
+
102
+ export interface TextProps extends React.HTMLAttributes<HTMLElement> {
103
+ /** HTML rendering tag */
104
+ as?: React.ElementType;
105
+ /** Typography scale */
106
+ size?: ComponentSize | "lead";
107
+ /** Text color / intent */
108
+ color?: ComponentColor | "muted" | "error" | "success" | "default";
109
+ /** Legacy variant prop for convenience / backward compatibility */
110
+ variant?: "default" | "muted" | "small" | "large" | "lead" | "error" | "success";
111
+ /** Font weight */
112
+ weight?: "normal" | "medium" | "semibold" | "bold";
113
+ /** Text alignment */
114
+ align?: "left" | "center" | "right";
115
+ children?: React.ReactNode;
116
+ }
117
+
118
+ export const Text = ({
119
+ as: Component = "p",
120
+ size,
121
+ color,
122
+ variant,
123
+ weight,
124
+ align,
125
+ className,
126
+ children,
127
+ ...props
128
+ }: TextProps) => {
129
+ const resolvedSize =
130
+ size ||
131
+ (variant === "small"
132
+ ? "sm"
133
+ : variant === "large"
134
+ ? "lg"
135
+ : variant === "lead"
136
+ ? "lead"
137
+ : "md");
138
+
139
+ const resolvedColor =
140
+ color ||
141
+ (variant === "muted"
142
+ ? "muted"
143
+ : variant === "error"
144
+ ? "error"
145
+ : variant === "success"
146
+ ? "success"
147
+ : "default");
148
+
149
+ return React.createElement(
150
+ Component,
151
+ {
152
+ className: cn(
153
+ textSizeClasses[resolvedSize] || textSizeClasses.md,
154
+ textColorClasses[resolvedColor] || textColorClasses.default,
155
+ weight && weightClasses[weight],
156
+ align && `pb:text-${align}`,
157
+ className,
158
+ ),
159
+ ...props,
160
+ },
161
+ children,
162
+ );
163
+ };
@@ -0,0 +1,6 @@
1
+ export * from "./Typography";
2
+ export * from "./Icon";
3
+ export { iconPaths } from "./IconRegistry";
4
+ export * from "./SocialLinks";
5
+ export * from "./CodeBlock";
6
+ export * from "./Anchor";
package/src/ui/index.ts CHANGED
@@ -1,28 +1,8 @@
1
- export * from "./Button";
2
- export * from "./Input";
3
- export * from "./Select";
4
- export * from "./Typography";
5
- export { iconPaths } from "./IconRegistry";
6
- export * from "./Icon";
7
- export * from "./Slider";
8
- export * from "./SliderWithNumberField";
9
- export * from "./Tooltip";
1
+ export * from "./types";
10
2
  export * from "./utils";
11
- export * from "./Dialog";
12
- export * from "./Popover";
13
- export * from "./Dropdown";
14
- export * from "./Collapsible";
15
- export * from "./Switch";
16
- export * from "./Label";
17
- export * from "./TabRadio";
18
- export * from "./NumberField";
19
- export * from "./Field";
20
- export * from "./Anchor";
21
- export * from "./TextArea";
22
- export * from "./Checkbox";
23
- export * from "./SettingsPanel";
24
- export * from "./Separator";
25
- export * from "./Skeleton";
26
- export * from "./AuthFormUI";
27
- export * from "./Alert";
28
- export * from "./TabSelection";
3
+ export * from "./foundations";
4
+ export * from "./forms";
5
+ export * from "./layout";
6
+ export * from "./media";
7
+ export * from "./feedback";
8
+ export * from "./patterns";
@@ -0,0 +1,86 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { Accordion } from "./Accordion";
3
+ import { Button } from "../forms/Button";
4
+ import { Input } from "../forms/Input";
5
+ import React from "react";
6
+
7
+ const meta: Meta<typeof Accordion> = {
8
+ title: "Layout/Accordion",
9
+ component: Accordion,
10
+ parameters: {
11
+ layout: "centered",
12
+ },
13
+ };
14
+
15
+ export default meta;
16
+
17
+ type Story = StoryObj<typeof Accordion>;
18
+
19
+ export const Default: Story = {
20
+ args: {
21
+ className: "pb:w-[480px]",
22
+ items: [
23
+ {
24
+ question: "What is Peerbots?",
25
+ answer: "Peerbots is an open-source platform designed to make social robotics accessible to educators, researchers, and therapists.",
26
+ isOpenDefault: true,
27
+ },
28
+ {
29
+ question: "How do I connect a controller to a robot face?",
30
+ answer: "You can pair controller and face apps using the same network or Peerbots cloud relay.",
31
+ },
32
+ {
33
+ question: "Is Peerbots open source?",
34
+ answer: "Yes, Peerbots core packages and tools are open-source under GPL/MIT licenses.",
35
+ },
36
+ ],
37
+ allowMultiple: false,
38
+ },
39
+ };
40
+
41
+ export const SingleDisclosure: Story = {
42
+ render: () => (
43
+ <div className="pb:w-[480px]">
44
+ <Accordion title="Advanced Configuration" defaultOpen={true}>
45
+ <div className="pb:space-y-3 pb:py-2">
46
+ <Input placeholder="Custom WebSocket URL" />
47
+ <Button variant="primary" size="sm">Save Config</Button>
48
+ </div>
49
+ </Accordion>
50
+ </div>
51
+ ),
52
+ };
53
+
54
+ export const Variants: Story = {
55
+ render: () => (
56
+ <div className="pb:w-[540px] pb:space-y-6 pb:p-6 pb:bg-gray-50 pb:rounded-2xl">
57
+ <div>
58
+ <h4 className="pb:text-xs pb:font-bold pb:text-gray-500 pb:uppercase pb:mb-2">Default Card</h4>
59
+ <Accordion
60
+ items={[
61
+ { question: "Item 1", answer: "Clean rounded card with shadow." },
62
+ { question: "Item 2", answer: "Second item." },
63
+ ]}
64
+ />
65
+ </div>
66
+ <div>
67
+ <h4 className="pb:text-xs pb:font-bold pb:text-gray-500 pb:uppercase pb:mb-2">Bordered 2px</h4>
68
+ <Accordion
69
+ variant="bordered"
70
+ items={[
71
+ { question: "Bordered Item 1", answer: "Clear border styling." },
72
+ ]}
73
+ />
74
+ </div>
75
+ <div>
76
+ <h4 className="pb:text-xs pb:font-bold pb:text-gray-500 pb:uppercase pb:mb-2">Flat Background</h4>
77
+ <Accordion
78
+ variant="flat"
79
+ items={[
80
+ { question: "Flat Item 1", answer: "Gray background without border." },
81
+ ]}
82
+ />
83
+ </div>
84
+ </div>
85
+ ),
86
+ };
@@ -0,0 +1,205 @@
1
+ import React, { useState, useId } from "react";
2
+ import { cn } from "../utils";
3
+ import { Icon } from "../foundations/Icon";
4
+ import { Text } from "../foundations/Typography";
5
+
6
+ export interface AccordionItem {
7
+ id?: string;
8
+ question?: React.ReactNode;
9
+ title?: React.ReactNode;
10
+ answer?: React.ReactNode;
11
+ content?: React.ReactNode;
12
+ description?: React.ReactNode;
13
+ bodyHtml?: string;
14
+ isOpenDefault?: boolean;
15
+ }
16
+
17
+ export interface AccordionProps {
18
+ /** List of accordion items for multi-item list mode */
19
+ items?: AccordionItem[];
20
+ /** Allow multiple items to be opened simultaneously */
21
+ allowMultiple?: boolean;
22
+ /** Visual style variant */
23
+ variant?: "default" | "bordered" | "flat";
24
+ /** Single-item mode: title for the disclosure */
25
+ title?: React.ReactNode;
26
+ /** Single-item mode: content/children for the disclosure */
27
+ children?: React.ReactNode;
28
+ /** Single-item mode: default open state */
29
+ defaultOpen?: boolean;
30
+ /** Controlled open state for single-item mode */
31
+ isOpen?: boolean;
32
+ /** Controlled toggle callback for single-item mode */
33
+ onToggle?: (open: boolean) => void;
34
+ className?: string;
35
+ headerClassName?: string;
36
+ contentClassName?: string;
37
+ }
38
+
39
+ export const Accordion: React.FC<AccordionProps> = ({
40
+ items,
41
+ allowMultiple = false,
42
+ variant = "default",
43
+ title,
44
+ children,
45
+ defaultOpen = false,
46
+ isOpen: controlledIsOpen,
47
+ onToggle,
48
+ className,
49
+ headerClassName,
50
+ contentClassName,
51
+ }) => {
52
+ const baseId = useId();
53
+
54
+ // Normalize single-item props vs items array
55
+ const resolvedItems: AccordionItem[] =
56
+ items ||
57
+ (title || children
58
+ ? [
59
+ {
60
+ id: "single",
61
+ title,
62
+ content: children,
63
+ isOpenDefault: defaultOpen,
64
+ },
65
+ ]
66
+ : []);
67
+
68
+ const [openIndices, setOpenIndices] = useState<number[]>(() => {
69
+ const initial: number[] = [];
70
+ resolvedItems.forEach((item, index) => {
71
+ if (item.isOpenDefault || (index === 0 && defaultOpen)) {
72
+ initial.push(index);
73
+ }
74
+ });
75
+ return initial;
76
+ });
77
+
78
+ const toggleItem = (index: number) => {
79
+ const isCurrentlyOpen =
80
+ controlledIsOpen !== undefined && resolvedItems.length === 1
81
+ ? controlledIsOpen
82
+ : openIndices.includes(index);
83
+ const willBeOpen = !isCurrentlyOpen;
84
+
85
+ if (onToggle && resolvedItems.length === 1) {
86
+ onToggle(willBeOpen);
87
+ }
88
+
89
+ if (controlledIsOpen === undefined) {
90
+ if (allowMultiple) {
91
+ setOpenIndices((prev) =>
92
+ prev.includes(index)
93
+ ? prev.filter((i) => i !== index)
94
+ : [...prev, index],
95
+ );
96
+ } else {
97
+ setOpenIndices((prev) => (prev.includes(index) ? [] : [index]));
98
+ }
99
+ }
100
+ };
101
+
102
+ return (
103
+ <div className={cn("pb:w-full pb:space-y-3 pb:box-border", className)}>
104
+ {resolvedItems.map((item, index) => {
105
+ const isOpen =
106
+ controlledIsOpen !== undefined && resolvedItems.length === 1
107
+ ? controlledIsOpen
108
+ : openIndices.includes(index);
109
+
110
+ const headingContent =
111
+ item.question || item.title || `Section ${index + 1}`;
112
+ const bodyContent =
113
+ item.content || item.answer || item.description;
114
+
115
+ const headerId = `${baseId}-header-${index}`;
116
+ const panelId = `${baseId}-panel-${index}`;
117
+
118
+ return (
119
+ <div
120
+ key={item.id || index}
121
+ className={cn(
122
+ "pb:w-full pb:rounded-2xl pb:border pb:transition-all pb:duration-300 pb:overflow-hidden pb:box-border",
123
+ isOpen
124
+ ? "pb:bg-white pb:border-peerbots-teal/40 pb:shadow-xs"
125
+ : "pb:bg-white/80 pb:hover:bg-white pb:border-gray-200/80",
126
+ variant === "bordered" && "pb:border-2",
127
+ variant === "flat" && "pb:border-none pb:bg-gray-100",
128
+ )}
129
+ >
130
+ <div className="pb:w-full pb:m-0 pb:p-0">
131
+ <button
132
+ type="button"
133
+ id={headerId}
134
+ aria-expanded={isOpen}
135
+ aria-controls={panelId}
136
+ onClick={() => toggleItem(index)}
137
+ className={cn(
138
+ "pb:w-full pb:box-border pb:px-5 pb:py-4 sm:pb:px-6 sm:pb:py-4.5 pb:text-left pb:flex pb:items-center pb:justify-between pb:gap-4 pb:font-bold pb:text-gray-900 pb:hover:text-peerbots-darkteal pb:transition-colors pb:focus:outline-none pb:focus-visible:ring-2 pb:focus-visible:ring-peerbots-teal pb:cursor-pointer",
139
+ headerClassName,
140
+ )}
141
+ >
142
+ <div className="pb:text-base sm:pb:text-lg pb:font-bold pb:leading-snug pb:flex-1 pb:min-w-0">
143
+ {typeof headingContent === "string" ? (
144
+ <span>{headingContent}</span>
145
+ ) : (
146
+ headingContent
147
+ )}
148
+ </div>
149
+ <span
150
+ className={cn(
151
+ "pb:w-7 pb:h-7 pb:rounded-full pb:flex pb:items-center pb:justify-center pb:flex-shrink-0 pb:transition-transform pb:duration-300 pb:ease-in-out",
152
+ isOpen
153
+ ? "pb:bg-peerbots-teal pb:text-white pb:rotate-180"
154
+ : "pb:bg-gray-100 pb:text-gray-600",
155
+ )}
156
+ aria-hidden="true"
157
+ >
158
+ <Icon
159
+ name="chevronDownSmall"
160
+ className="pb:w-4 pb:h-4"
161
+ />
162
+ </span>
163
+ </button>
164
+ </div>
165
+
166
+ {/* Smooth animated expand / collapse container */}
167
+ <div
168
+ id={panelId}
169
+ role="region"
170
+ aria-labelledby={headerId}
171
+ className={cn(
172
+ "pb:grid pb:transition-all pb:duration-300 pb:ease-in-out",
173
+ isOpen
174
+ ? "pb:grid-rows-[1fr] pb:opacity-100"
175
+ : "pb:grid-rows-[0fr] pb:opacity-0",
176
+ )}
177
+ >
178
+ <div className="pb:overflow-hidden">
179
+ <div
180
+ className={cn(
181
+ "pb:w-full pb:box-border pb:px-5 pb:pb-5 pb:pt-2 sm:pb:px-6 pb:text-gray-700 pb:leading-relaxed pb:border-t pb:border-gray-100",
182
+ contentClassName,
183
+ )}
184
+ >
185
+ {item.bodyHtml ? (
186
+ <div
187
+ className="pb:text-base pb:leading-relaxed"
188
+ dangerouslySetInnerHTML={{ __html: item.bodyHtml }}
189
+ />
190
+ ) : typeof bodyContent === "string" ? (
191
+ <Text className="pb:text-base pb:text-gray-700 pb:leading-relaxed">
192
+ {bodyContent}
193
+ </Text>
194
+ ) : (
195
+ bodyContent
196
+ )}
197
+ </div>
198
+ </div>
199
+ </div>
200
+ </div>
201
+ );
202
+ })}
203
+ </div>
204
+ );
205
+ };