@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.
- package/.changeset/config.json +1 -1
- package/.github/workflows/storybook.yml +9 -1
- package/CHANGELOG.md +94 -0
- package/dist/index.d.mts +643 -131
- package/dist/index.d.ts +643 -131
- package/dist/index.js +7 -6
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +7 -6
- package/dist/index.mjs.map +1 -1
- package/package.json +6 -3
- package/src/charts/DistributionBarChart.tsx +10 -4
- package/src/charts/DistributionHistogram.tsx +13 -5
- package/src/charts/LineGraph.stories.tsx +60 -0
- package/src/charts/LineGraph.tsx +216 -0
- package/src/charts/index.ts +2 -0
- package/src/docs/DesignSystem.mdx +241 -25
- package/src/styles/theme.css +8 -0
- package/src/ui/feedback/Alert.stories.tsx +56 -0
- package/src/ui/feedback/Alert.tsx +168 -0
- package/src/ui/{Dialog.stories.tsx → feedback/Dialog.stories.tsx} +2 -2
- package/src/ui/{Dialog.tsx → feedback/Dialog.tsx} +2 -2
- package/src/ui/feedback/EmptyState.stories.tsx +57 -0
- package/src/ui/feedback/EmptyState.tsx +123 -0
- package/src/ui/feedback/Popover.stories.tsx +130 -0
- package/src/ui/feedback/Popover.tsx +135 -0
- package/src/ui/feedback/Skeleton.stories.tsx +88 -0
- package/src/ui/{Skeleton.tsx → feedback/Skeleton.tsx} +2 -2
- package/src/ui/feedback/Spinner.stories.tsx +77 -0
- package/src/ui/feedback/Spinner.tsx +62 -0
- package/src/ui/{Tooltip.stories.tsx → feedback/Tooltip.stories.tsx} +3 -3
- package/src/ui/{Tooltip.tsx → feedback/Tooltip.tsx} +2 -2
- package/src/ui/feedback/index.ts +7 -0
- package/src/ui/forms/Button.stories.tsx +181 -0
- package/src/ui/forms/Button.tsx +323 -0
- package/src/ui/{Checkbox.stories.tsx → forms/Checkbox.stories.tsx} +2 -2
- package/src/ui/{Checkbox.tsx → forms/Checkbox.tsx} +2 -2
- package/src/ui/forms/ChipGroup.stories.tsx +118 -0
- package/src/ui/forms/ChipGroup.tsx +239 -0
- package/src/ui/{Dropdown.tsx → forms/Dropdown.tsx} +1 -1
- package/src/ui/{Field.stories.tsx → forms/Field.stories.tsx} +3 -3
- package/src/ui/{Field.tsx → forms/Field.tsx} +1 -1
- package/src/ui/{Input.stories.tsx → forms/Input.stories.tsx} +6 -6
- package/src/ui/{Input.tsx → forms/Input.tsx} +1 -1
- package/src/ui/{Label.stories.tsx → forms/Label.stories.tsx} +2 -2
- package/src/ui/{Label.tsx → forms/Label.tsx} +2 -2
- package/src/ui/{NumberField.stories.tsx → forms/NumberField.stories.tsx} +2 -2
- package/src/ui/{NumberField.tsx → forms/NumberField.tsx} +8 -9
- package/src/ui/forms/PeerbotsColorSelector.stories.tsx +49 -0
- package/src/ui/forms/PeerbotsColorSelector.tsx +100 -0
- package/src/ui/{Select.stories.tsx → forms/Select.stories.tsx} +2 -2
- package/src/ui/{Select.tsx → forms/Select.tsx} +5 -3
- package/src/ui/{Slider.stories.tsx → forms/Slider.stories.tsx} +6 -4
- package/src/ui/{Slider.tsx → forms/Slider.tsx} +7 -3
- package/src/ui/forms/SliderWithNumberField.stories.tsx +136 -0
- package/src/ui/{SliderWithNumberField.tsx → forms/SliderWithNumberField.tsx} +19 -7
- package/src/ui/{Switch.stories.tsx → forms/Switch.stories.tsx} +2 -2
- package/src/ui/{Switch.tsx → forms/Switch.tsx} +1 -1
- package/src/ui/{TabRadio.stories.tsx → forms/TabRadio.stories.tsx} +3 -3
- package/src/ui/{TabRadio.tsx → forms/TabRadio.tsx} +1 -1
- package/src/ui/{TextArea.stories.tsx → forms/TextArea.stories.tsx} +5 -3
- package/src/ui/{TextArea.tsx → forms/TextArea.tsx} +1 -1
- package/src/ui/forms/index.ts +15 -0
- package/src/ui/foundations/Anchor.stories.tsx +39 -0
- package/src/ui/foundations/Anchor.tsx +96 -0
- package/src/ui/foundations/CodeBlock.stories.tsx +86 -0
- package/src/ui/foundations/CodeBlock.tsx +148 -0
- package/src/ui/foundations/Colors.stories.tsx +80 -0
- package/src/ui/{Icon.stories.tsx → foundations/Icon.stories.tsx} +1 -1
- package/src/ui/{Icon.tsx → foundations/Icon.tsx} +1 -1
- package/src/ui/{IconRegistry.tsx → foundations/IconRegistry.tsx} +261 -57
- package/src/ui/foundations/SocialLinks.stories.tsx +38 -0
- package/src/ui/foundations/SocialLinks.tsx +117 -0
- package/src/ui/foundations/Typography.stories.tsx +58 -0
- package/src/ui/foundations/Typography.tsx +163 -0
- package/src/ui/foundations/index.ts +6 -0
- package/src/ui/index.ts +7 -27
- package/src/ui/layout/Accordion.stories.tsx +86 -0
- package/src/ui/layout/Accordion.tsx +205 -0
- package/src/ui/layout/Card.stories.tsx +151 -0
- package/src/ui/layout/Card.tsx +124 -0
- package/src/ui/layout/Collapsible.stories.tsx +54 -0
- package/src/ui/layout/Collapsible.tsx +54 -0
- package/src/ui/{Separator.stories.tsx → layout/Separator.stories.tsx} +2 -2
- package/src/ui/{Separator.tsx → layout/Separator.tsx} +1 -1
- package/src/ui/{SettingsPanel.stories.tsx → layout/SettingsPanel.stories.tsx} +5 -5
- package/src/ui/{SettingsPanel.tsx → layout/SettingsPanel.tsx} +33 -29
- package/src/ui/layout/index.ts +5 -0
- package/src/ui/media/Avatar.stories.tsx +89 -0
- package/src/ui/media/Avatar.tsx +122 -0
- package/src/ui/media/Dropzone.stories.tsx +95 -0
- package/src/ui/media/Dropzone.tsx +328 -0
- package/src/ui/media/FileUpload.stories.tsx +62 -0
- package/src/ui/media/FileUpload.tsx +175 -0
- package/src/ui/media/index.ts +3 -0
- package/src/ui/{AuthFormUI.stories.tsx → patterns/AuthFormUI.stories.tsx} +1 -1
- package/src/ui/{AuthFormUI.tsx → patterns/AuthFormUI.tsx} +24 -23
- package/src/ui/patterns/CopyButton.stories.tsx +77 -0
- package/src/ui/patterns/CopyButton.tsx +93 -0
- package/src/ui/patterns/SearchInput.stories.tsx +51 -0
- package/src/ui/patterns/SearchInput.tsx +93 -0
- package/src/ui/patterns/Stepper.stories.tsx +129 -0
- package/src/ui/patterns/Stepper.tsx +295 -0
- package/src/ui/{TabSelection.stories.tsx → patterns/TabSelection.stories.tsx} +1 -1
- package/src/ui/{TabSelection.tsx → patterns/TabSelection.tsx} +2 -2
- package/src/ui/patterns/index.ts +6 -0
- package/src/ui/patterns/useClipboard.ts +75 -0
- package/src/ui/types.ts +48 -0
- package/src/ui/Alert.stories.tsx +0 -41
- package/src/ui/Alert.tsx +0 -72
- package/src/ui/Anchor.stories.tsx +0 -25
- package/src/ui/Anchor.tsx +0 -32
- package/src/ui/Button.stories.tsx +0 -108
- package/src/ui/Button.tsx +0 -121
- package/src/ui/Collapsible.stories.tsx +0 -91
- package/src/ui/Collapsible.tsx +0 -52
- package/src/ui/Colors.stories.tsx +0 -67
- package/src/ui/Popover.tsx +0 -42
- package/src/ui/Skeleton.stories.tsx +0 -43
- package/src/ui/SliderWithNumberField.stories.tsx +0 -101
- package/src/ui/Typography.stories.tsx +0 -87
- 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";
|
|
@@ -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
|
-
|
|
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"
|
|
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"
|
|
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="
|
|
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="
|
|
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-
|
|
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't have an account?{" "}
|
|
152
153
|
<Button
|
|
153
|
-
variant="
|
|
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't have an account?{" "}
|
|
167
168
|
<Button
|
|
168
|
-
variant="
|
|
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-
|
|
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="
|
|
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
|
-
|
|
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";
|