@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,175 @@
1
+ import React, { useRef, useState } from "react";
2
+ import { Button, ButtonProps } from "../forms/Button";
3
+ import { Icon } from "../foundations/Icon";
4
+ import { cn } from "../utils";
5
+
6
+ export interface FileUploadProps {
7
+ /** Accepted file types (e.g. `"image/*"`, `".pdf"`, `"audio/*"`). */
8
+ accept?: string;
9
+ /** Whether multiple files can be selected. */
10
+ multiple?: boolean;
11
+ /** Whether the input is disabled. */
12
+ disabled?: boolean;
13
+ /** Maximum allowable file size in bytes. */
14
+ maxSizeBytes?: number;
15
+ /** Text displayed inside the trigger button. Default is `"Choose File"`. */
16
+ buttonText?: React.ReactNode;
17
+ /** Button style variant. Default is `"secondary"`. */
18
+ buttonVariant?: ButtonProps["variant"];
19
+ /** Button size. Default is `"md"`. */
20
+ buttonSize?: ButtonProps["size"];
21
+ /** Icon shown inside the button. Default is `<Icon name="arrowUpTray" />`. */
22
+ buttonIcon?: React.ReactNode;
23
+ /** Text shown when no file has been chosen. Default is `"No file chosen"`. */
24
+ placeholder?: string;
25
+ /** Whether to show a clear/remove button when files are selected. Default is `true`. */
26
+ clearable?: boolean;
27
+ /** Controlled file(s) value. */
28
+ value?: File | File[] | null;
29
+ /** Callback fired when file selection changes. */
30
+ onChange?: (files: File | File[] | null) => void;
31
+ /** Callback fired when a file exceeds size limit or validation fails. */
32
+ onError?: (errorMessage: string) => void;
33
+ /** Optional custom CSS classes. */
34
+ className?: string;
35
+ id?: string;
36
+ name?: string;
37
+ }
38
+
39
+ function formatBytes(bytes: number, decimals = 1): string {
40
+ if (bytes === 0) return "0 Bytes";
41
+ const k = 1024;
42
+ const dm = decimals < 0 ? 0 : decimals;
43
+ const sizes = ["Bytes", "KB", "MB", "GB"];
44
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
45
+ return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + " " + sizes[i];
46
+ }
47
+
48
+ export const FileUpload = React.forwardRef<HTMLDivElement, FileUploadProps>(
49
+ (
50
+ {
51
+ accept,
52
+ multiple = false,
53
+ disabled = false,
54
+ maxSizeBytes,
55
+ buttonText = "Choose File",
56
+ buttonVariant = "secondary",
57
+ buttonSize = "md",
58
+ buttonIcon = <Icon name="arrowUpTray" />,
59
+ placeholder = "No file chosen",
60
+ clearable = true,
61
+ value,
62
+ onChange,
63
+ onError,
64
+ className,
65
+ id,
66
+ name,
67
+ },
68
+ ref,
69
+ ) => {
70
+ const inputRef = useRef<HTMLInputElement>(null);
71
+ const [internalFiles, setInternalFiles] = useState<File[]>([]);
72
+
73
+ const activeFiles = value
74
+ ? Array.isArray(value)
75
+ ? value
76
+ : [value]
77
+ : internalFiles;
78
+
79
+ const handleButtonClick = () => {
80
+ if (disabled) return;
81
+ inputRef.current?.click();
82
+ };
83
+
84
+ const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
85
+ const selected = e.target.files ? Array.from(e.target.files) : [];
86
+ if (selected.length === 0) return;
87
+
88
+ // Validate file sizes
89
+ if (maxSizeBytes) {
90
+ const oversized = selected.find((f) => f.size > maxSizeBytes);
91
+ if (oversized) {
92
+ const msg = `File "${oversized.name}" exceeds maximum allowed size of ${formatBytes(maxSizeBytes)}.`;
93
+ onError?.(msg);
94
+ if (inputRef.current) inputRef.current.value = "";
95
+ return;
96
+ }
97
+ }
98
+
99
+ setInternalFiles(selected);
100
+ if (onChange) {
101
+ onChange(multiple ? selected : selected[0] || null);
102
+ }
103
+ };
104
+
105
+ const handleClear = (e: React.MouseEvent) => {
106
+ e.stopPropagation();
107
+ setInternalFiles([]);
108
+ if (inputRef.current) inputRef.current.value = "";
109
+ if (onChange) {
110
+ onChange(multiple ? [] : null);
111
+ }
112
+ };
113
+
114
+ return (
115
+ <div
116
+ ref={ref}
117
+ className={cn(
118
+ "pb:inline-flex pb:items-center pb:gap-3 pb:w-full pb:max-w-md",
119
+ disabled && "pb:cursor-not-allowed",
120
+ className,
121
+ )}
122
+ >
123
+ <input
124
+ ref={inputRef}
125
+ type="file"
126
+ id={id}
127
+ name={name}
128
+ accept={accept}
129
+ multiple={multiple}
130
+ disabled={disabled}
131
+ onChange={handleFileChange}
132
+ className="pb:hidden"
133
+ tabIndex={-1}
134
+ />
135
+
136
+ <Button
137
+ type="button"
138
+ variant={buttonVariant}
139
+ size={buttonSize}
140
+ disabled={disabled}
141
+ leftIcon={buttonIcon}
142
+ onClick={handleButtonClick}
143
+ >
144
+ {buttonText}
145
+ </Button>
146
+
147
+ <div className="pb:flex-1 pb:min-w-0 pb:flex pb:items-center pb:justify-between pb:gap-2 pb:py-1 pb:px-2 pb:bg-gray-50 pb:border pb:border-gray-200 pb:rounded-md pb:text-xs">
148
+ {activeFiles.length > 0 ? (
149
+ <span className="pb:font-medium pb:text-gray-800 pb:truncate">
150
+ {activeFiles.length === 1
151
+ ? `${activeFiles[0].name} (${formatBytes(activeFiles[0].size)})`
152
+ : `${activeFiles.length} files selected`}
153
+ </span>
154
+ ) : (
155
+ <span className="pb:text-gray-600 pb:truncate">{placeholder}</span>
156
+ )}
157
+
158
+ {clearable && activeFiles.length > 0 && !disabled && (
159
+ <button
160
+ type="button"
161
+ onClick={handleClear}
162
+ className="pb:p-0.5 pb:text-gray-400 pb:hover:text-gray-700 pb:rounded pb:hover:bg-gray-200 pb:cursor-pointer pb:transition-colors"
163
+ title="Clear selection"
164
+ aria-label="Clear selected file"
165
+ >
166
+ <Icon name="x" className="pb:w-3.5 pb:h-3.5" />
167
+ </button>
168
+ )}
169
+ </div>
170
+ </div>
171
+ );
172
+ },
173
+ );
174
+
175
+ FileUpload.displayName = "FileUpload";
@@ -0,0 +1,3 @@
1
+ export * from "./Avatar";
2
+ export * from "./Dropzone";
3
+ export * from "./FileUpload";
@@ -3,7 +3,7 @@ import { AuthFormUI } from "./AuthFormUI";
3
3
  import { useState } from "react";
4
4
 
5
5
  const meta: Meta<typeof AuthFormUI> = {
6
- title: "UI/AuthFormUI",
6
+ title: "Patterns/AuthFormUI",
7
7
  component: AuthFormUI,
8
8
  parameters: {
9
9
  layout: "centered",
@@ -1,12 +1,13 @@
1
1
  import { useFormStatus } from "react-dom";
2
- import { Button, Input, Heading, Text, Icon, Field } from ".";
2
+ import { Button, Input, Heading, Text, Icon, Field } from "..";
3
+ import React from "react";
3
4
 
4
5
  function SubmitButton({ label }: { label: string }) {
5
6
  const { pending } = useFormStatus();
6
7
  return (
7
8
  <Button
8
9
  type="submit"
9
- variant="primary"
10
+ color="primary"
10
11
  isLoading={pending}
11
12
  disabled={pending}
12
13
  >
@@ -51,7 +52,7 @@ export function AuthFormUI({
51
52
 
52
53
  <form className="pb:md:m-10 pb:sm:m-4 pb:space-y-4" action={formAction}>
53
54
  {description && (
54
- <Text className="pb:text-center pb:mb-6" variant="muted">
55
+ <Text className="pb:text-center pb:mb-6" color="muted">
55
56
  {description}
56
57
  </Text>
57
58
  )}
@@ -61,7 +62,7 @@ export function AuthFormUI({
61
62
  {actionState.message}
62
63
  </Text>
63
64
  )}
64
- <Text className="pb:text-center" variant="error">
65
+ <Text className="pb:text-center" color="error">
65
66
  {actionState.error}
66
67
  </Text>
67
68
 
@@ -114,13 +115,13 @@ export function AuthFormUI({
114
115
 
115
116
  <div className="pb:text-center pb:text-sm pb:text-gray-500">
116
117
  {mode === "signing up" && (
117
- <Text>
118
+ <Text size="sm">
118
119
  Already have an account?{" "}
119
120
  <Button
120
- variant="ghost"
121
+ variant="link"
122
+ color="teal"
121
123
  size="sm"
122
124
  onClick={() => onModeChange("signing in")}
123
- className="pb:underline pb:p-0 pb:h-auto pb:hover:bg-transparent"
124
125
  >
125
126
  Sign in
126
127
  </Button>
@@ -128,13 +129,13 @@ export function AuthFormUI({
128
129
  )}
129
130
  {mode === "signing in" && (
130
131
  <div className="pb:flex pb:flex-col pb:gap-2">
131
- <Text>
132
+ <Text size="sm">
132
133
  Forgot your password?{" "}
133
134
  <Button
134
- variant="ghost"
135
+ variant="link"
136
+ color="teal"
135
137
  size="sm"
136
138
  onClick={() => onModeChange("resetting password")}
137
- className="pb:underline pb:p-0 pb:h-auto pb:hover:bg-transparent"
138
139
  >
139
140
  Reset password.
140
141
  </Button>
@@ -144,16 +145,16 @@ export function AuthFormUI({
144
145
  <div className="pb:w-full pb:border-t pb:border-gray-300"></div>
145
146
  </div>
146
147
  <div className="pb:relative pb:flex pb:justify-center pb:text-sm">
147
- <span className="pb:px-2 pb:bg-white pb:text-gray-500">or</span>
148
+ <span className="pb:px-2 pb:bg-white pb:text-gray-700">or</span>
148
149
  </div>
149
150
  </div>
150
- <Text>
151
+ <Text size="sm">
151
152
  Don&apos;t have an account?{" "}
152
153
  <Button
153
- variant="ghost"
154
+ variant="link"
155
+ color="teal"
154
156
  size="sm"
155
157
  onClick={() => onModeChange("signing up")}
156
- className="pb:underline pb:p-0 pb:h-auto pb:hover:bg-transparent"
157
158
  >
158
159
  Sign up
159
160
  </Button>
@@ -162,13 +163,13 @@ export function AuthFormUI({
162
163
  )}
163
164
  {mode === "resetting password" && (
164
165
  <div className="pb:flex pb:flex-col pb:gap-2">
165
- <Text>
166
+ <Text size="sm">
166
167
  Don&apos;t have an account?{" "}
167
168
  <Button
168
- variant="ghost"
169
+ variant="link"
170
+ color="teal"
169
171
  size="sm"
170
172
  onClick={() => onModeChange("signing up")}
171
- className="pb:underline pb:p-0 pb:h-auto pb:hover:bg-transparent"
172
173
  >
173
174
  Sign up
174
175
  </Button>
@@ -178,16 +179,16 @@ export function AuthFormUI({
178
179
  <div className="pb:w-full pb:border-t pb:border-gray-300"></div>
179
180
  </div>
180
181
  <div className="pb:relative pb:flex pb:justify-center pb:text-sm">
181
- <span className="pb:px-2 pb:bg-white pb:text-gray-500">or</span>
182
+ <span className="pb:px-2 pb:bg-white pb:text-gray-700">or</span>
182
183
  </div>
183
184
  </div>
184
- <Text>
185
- Remembered your password?
185
+ <Text size="sm">
186
+ Remembered your password?{" "}
186
187
  <Button
187
- variant="ghost"
188
+ variant="link"
189
+ color="teal"
188
190
  size="sm"
189
191
  onClick={() => onModeChange("signing in")}
190
- className="pb:underline pb:p-0 pb:h-auto pb:hover:bg-transparent"
191
192
  >
192
193
  Sign In
193
194
  </Button>
@@ -200,7 +201,7 @@ export function AuthFormUI({
200
201
  {mode !== "resetting password" && onGoogleSignIn && (
201
202
  <div className="pb:text-center pb:mt-4">
202
203
  <Button
203
- variant="secondary"
204
+ color="neutral"
204
205
  onClick={onGoogleSignIn}
205
206
  type="button"
206
207
  className="pb:w-full pb:flex pb:items-center pb:justify-center pb:gap-2"
@@ -0,0 +1,77 @@
1
+ import type { Meta, StoryObj } from "@storybook/react";
2
+ import { CopyButton } from "./CopyButton";
3
+ import { Heading } from "../foundations/Typography";
4
+
5
+ const meta: Meta<typeof CopyButton> = {
6
+ title: "Patterns/CopyButton",
7
+ component: CopyButton,
8
+ parameters: {
9
+ layout: "centered",
10
+ },
11
+ tags: ["autodocs"],
12
+ argTypes: {
13
+ variant: {
14
+ control: "select",
15
+ options: ["primary", "secondary", "danger", "ghost", "ghostly-danger"],
16
+ },
17
+ size: {
18
+ control: "select",
19
+ options: ["sm", "md", "lg"],
20
+ },
21
+ value: { control: "text" },
22
+ label: { control: "text" },
23
+ successLabel: { control: "text" },
24
+ timeout: { control: "number" },
25
+ },
26
+ };
27
+
28
+ export default meta;
29
+ type Story = StoryObj<typeof CopyButton>;
30
+
31
+ export const Default: Story = {
32
+ args: {
33
+ value: "https://peerbots.org/session/12345",
34
+ label: "Copy Link",
35
+ successLabel: "Link Copied!",
36
+ variant: "secondary",
37
+ },
38
+ };
39
+
40
+ export const Variations: Story = {
41
+ render: () => (
42
+ <div className="pb:flex pb:flex-col pb:gap-6 pb:p-4">
43
+ <div className="pb:space-y-3">
44
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
45
+ Variants
46
+ </Heading>
47
+ <div className="pb:flex pb:flex-wrap pb:gap-3 pb:items-center">
48
+ <CopyButton value="Code Snippet" variant="primary" label="Copy Primary" />
49
+ <CopyButton value="Code Snippet" variant="secondary" label="Copy Secondary" />
50
+ <CopyButton value="Code Snippet" variant="ghost" label="Copy Ghost" />
51
+ </div>
52
+ </div>
53
+
54
+ <div className="pb:space-y-3">
55
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
56
+ Sizes
57
+ </Heading>
58
+ <div className="pb:flex pb:flex-wrap pb:gap-3 pb:items-center">
59
+ <CopyButton value="Short Link" size="sm" label="Small" />
60
+ <CopyButton value="Short Link" size="md" label="Medium" />
61
+ <CopyButton value="Short Link" size="lg" label="Large" />
62
+ </div>
63
+ </div>
64
+
65
+ <div className="pb:space-y-3">
66
+ <Heading level={4} className="pb:text-sm pb:font-medium pb:text-black">
67
+ Icon Only
68
+ </Heading>
69
+ <div className="pb:flex pb:flex-wrap pb:gap-3 pb:items-center">
70
+ <CopyButton value="Secret Token" isIconOnly size="sm" />
71
+ <CopyButton value="Secret Token" isIconOnly size="md" />
72
+ <CopyButton value="Secret Token" isIconOnly size="lg" />
73
+ </div>
74
+ </div>
75
+ </div>
76
+ ),
77
+ };
@@ -0,0 +1,93 @@
1
+ import React from "react";
2
+ import { Button, ButtonProps } from "../forms/Button";
3
+ import { Icon } from "../foundations/Icon";
4
+ import { useClipboard } from "./useClipboard";
5
+ import { cn } from "../utils";
6
+
7
+ export interface CopyButtonProps extends Omit<ButtonProps, "onClick"> {
8
+ /** The text value to copy to the clipboard. */
9
+ value: string;
10
+ /** Label displayed before copying. Default is `"Copy"`. Pass `null` or use `isIconOnly` for icon-only. */
11
+ label?: React.ReactNode;
12
+ /** Label displayed after copying. Default is `"Copied!"`. */
13
+ successLabel?: React.ReactNode;
14
+ /** Duration in milliseconds for the success state. Default is `2000`. */
15
+ timeout?: number;
16
+ /** Callback fired after successfully copying to clipboard. */
17
+ onCopied?: (value: string) => void;
18
+ /** Callback fired if copying fails. */
19
+ onCopyError?: (error: Error) => void;
20
+ /** Custom icon shown in default state. */
21
+ copyIcon?: React.ReactNode;
22
+ /** Custom icon shown in copied state. */
23
+ successIcon?: React.ReactNode;
24
+ /** Whether to show icons. Default is `true`. */
25
+ showIcon?: boolean;
26
+ }
27
+
28
+ export const CopyButton = React.forwardRef<HTMLButtonElement, CopyButtonProps>(
29
+ (
30
+ {
31
+ value,
32
+ label = "Copy",
33
+ successLabel = "Copied!",
34
+ timeout = 2000,
35
+ onCopied,
36
+ onCopyError,
37
+ copyIcon,
38
+ successIcon,
39
+ showIcon = true,
40
+ variant = "outline",
41
+ color = "neutral",
42
+ size = "md",
43
+ radius = "md",
44
+ isIconOnly,
45
+ className,
46
+ ...props
47
+ },
48
+ ref,
49
+ ) => {
50
+ const { copy, copied } = useClipboard({
51
+ timeout,
52
+ onSuccess: onCopied,
53
+ onError: onCopyError,
54
+ });
55
+
56
+ const currentIcon = copied
57
+ ? successIcon || <Icon name="check" className="pb:text-peerbots-teal" />
58
+ : copyIcon || <Icon name="clipboard" />;
59
+
60
+ const currentLabel = copied ? successLabel : label;
61
+
62
+ return (
63
+ <Button
64
+ ref={ref}
65
+ variant={variant}
66
+ color={color}
67
+ size={size}
68
+ radius={radius}
69
+ isIconOnly={isIconOnly}
70
+ onClick={() => copy(value)}
71
+ leftIcon={showIcon ? currentIcon : undefined}
72
+ className={cn(
73
+ "pb:transition-all pb:duration-150",
74
+ copied && "pb:border-peerbots-teal pb:text-peerbots-darkteal",
75
+ className,
76
+ )}
77
+ aria-live="polite"
78
+ aria-label={
79
+ isIconOnly
80
+ ? typeof currentLabel === "string"
81
+ ? currentLabel
82
+ : "Copy"
83
+ : undefined
84
+ }
85
+ {...props}
86
+ >
87
+ {isIconOnly ? null : currentLabel}
88
+ </Button>
89
+ );
90
+ },
91
+ );
92
+
93
+ CopyButton.displayName = "CopyButton";
@@ -0,0 +1,51 @@
1
+ import type { Meta, StoryObj } from "@storybook/react-vite";
2
+ import { useState } from "react";
3
+ import { SearchInput } from "./SearchInput";
4
+
5
+ const meta: Meta<typeof SearchInput> = {
6
+ title: "Patterns/SearchInput",
7
+ component: SearchInput,
8
+ };
9
+
10
+ export default meta;
11
+ type Story = StoryObj<typeof SearchInput>;
12
+
13
+ function ControlledSearchInput(props: React.ComponentProps<typeof SearchInput>) {
14
+ const [val, setVal] = useState(props.value ?? "");
15
+ return (
16
+ <div className="pb:max-w-xs">
17
+ <SearchInput
18
+ {...props}
19
+ value={val}
20
+ onChange={(e) => setVal(e.target.value)}
21
+ onClear={() => setVal("")}
22
+ />
23
+ <span className="pb:text-xs pb:text-gray-600 pb:mt-1 pb:block">
24
+ Current value: {val ? `"${val}"` : "empty"}
25
+ </span>
26
+ </div>
27
+ );
28
+ }
29
+
30
+ export const Default: Story = {
31
+ render: () => <ControlledSearchInput placeholder="Search collections..." />,
32
+ };
33
+
34
+ export const Variations: Story = {
35
+ render: () => (
36
+ <div className="pb:flex pb:flex-col pb:gap-6 pb:p-4">
37
+ <div>
38
+ <span className="pb:text-xs pb:font-bold pb:text-gray-700 pb:block pb:mb-2">Prefilled with Clear Button</span>
39
+ <ControlledSearchInput value="Robot templates" />
40
+ </div>
41
+ <div>
42
+ <span className="pb:text-xs pb:font-bold pb:text-gray-700 pb:block pb:mb-2">Disabled State</span>
43
+ <SearchInput disabled placeholder="Search is disabled" className="pb:max-w-xs" />
44
+ </div>
45
+ <div>
46
+ <span className="pb:text-xs pb:font-bold pb:text-gray-700 pb:block pb:mb-2">Without Clear Button</span>
47
+ <ControlledSearchInput showClearButton={false} placeholder="No clear button..." />
48
+ </div>
49
+ </div>
50
+ ),
51
+ };
@@ -0,0 +1,93 @@
1
+ import React, { useRef, useImperativeHandle } from "react";
2
+ import { Icon } from "../foundations/Icon";
3
+ import { Input, InputProps } from "../forms/Input";
4
+ import { cn } from "../utils";
5
+
6
+ export interface SearchInputProps
7
+ extends Omit<InputProps, "leftIcon" | "rightIcon"> {
8
+ onSearch?: (query: string) => void;
9
+ onClear?: () => void;
10
+ showClearButton?: boolean;
11
+ }
12
+
13
+ export const SearchInput = React.forwardRef<HTMLInputElement, SearchInputProps>(
14
+ (
15
+ {
16
+ className,
17
+ value,
18
+ defaultValue,
19
+ onChange,
20
+ onSearch,
21
+ onClear,
22
+ showClearButton = true,
23
+ placeholder = "Search...",
24
+ onKeyDown,
25
+ ...props
26
+ },
27
+ ref,
28
+ ) => {
29
+ const internalRef = useRef<HTMLInputElement>(null);
30
+ useImperativeHandle(ref, () => internalRef.current as HTMLInputElement);
31
+
32
+ const handleClear = () => {
33
+ if (internalRef.current) {
34
+ internalRef.current.value = "";
35
+ }
36
+ if (onClear) {
37
+ onClear();
38
+ }
39
+ if (onSearch) {
40
+ onSearch("");
41
+ }
42
+ internalRef.current?.focus();
43
+ };
44
+
45
+ const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
46
+ if (e.key === "Enter" && onSearch) {
47
+ onSearch(internalRef.current?.value ?? "");
48
+ } else if (e.key === "Escape") {
49
+ handleClear();
50
+ }
51
+ if (onKeyDown) {
52
+ onKeyDown(e);
53
+ }
54
+ };
55
+
56
+ const hasValue =
57
+ value !== undefined
58
+ ? String(value).length > 0
59
+ : internalRef.current?.value && internalRef.current.value.length > 0;
60
+
61
+ return (
62
+ <div className={cn("pb:relative pb:w-full", className)}>
63
+ <Input
64
+ ref={internalRef}
65
+ type="text"
66
+ value={value}
67
+ defaultValue={defaultValue}
68
+ placeholder={placeholder}
69
+ onChange={onChange}
70
+ onKeyDown={handleKeyDown}
71
+ leftIcon={<Icon name="search" className="pb:w-4 pb:h-4 pb:text-gray-400" />}
72
+ className="pb:pr-8"
73
+ {...props}
74
+ />
75
+ {showClearButton && (
76
+ <button
77
+ type="button"
78
+ aria-label="Clear search"
79
+ onClick={handleClear}
80
+ className={cn(
81
+ "pb:absolute pb:inset-y-0 pb:right-0 pb:flex pb:items-center pb:pr-2.5 pb:text-gray-400 pb:hover:text-gray-600 pb:cursor-pointer pb:transition-opacity pb:duration-150 pb:focus-visible:outline-none pb:focus-visible:ring-2 pb:focus-visible:ring-primary pb:rounded-r-md",
82
+ hasValue ? "pb:opacity-100 pb:visible" : "pb:opacity-0 pb:invisible",
83
+ )}
84
+ >
85
+ <Icon name="xCircle" className="pb:w-4 pb:h-4" />
86
+ </button>
87
+ )}
88
+ </div>
89
+ );
90
+ },
91
+ );
92
+
93
+ SearchInput.displayName = "SearchInput";