Skip to content

Commit

Permalink
add form control (#59)
Browse files Browse the repository at this point in the history
  • Loading branch information
jeremy-babylonlabs authored Dec 11, 2024
1 parent 1b3530d commit a8049c4
Show file tree
Hide file tree
Showing 11 changed files with 201 additions and 62 deletions.
5 changes: 5 additions & 0 deletions .changeset/famous-dancers-raise.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@babylonlabs-io/bbn-core-ui": minor
---

add form control component
16 changes: 0 additions & 16 deletions src/components/Form/Input.css
Original file line number Diff line number Diff line change
Expand Up @@ -36,20 +36,4 @@
&-prefix {
@apply mr-2 flex items-center text-primary-light/50;
}

&-state-text {
@apply mt-1 text-sm;
}

&-state-text-error {
@apply text-error-main;
}

&-state-text-warning {
@apply text-warning-main;
}

&-state-text-success {
@apply text-success-main;
}
}
4 changes: 2 additions & 2 deletions src/components/Form/Input.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,11 @@ export const Disabled: Story = {
},
};

export const Error: Story = {
export const WithError: Story = {
args: {
placeholder: "Input with error",
state: "error",
stateText: "This field is required",
hint: "This field is required",
},
};

Expand Down
16 changes: 9 additions & 7 deletions src/components/Form/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { forwardRef, type DetailedHTMLProps, type InputHTMLAttributes, type ReactNode } from "react";
import { twJoin } from "tailwind-merge";
import "./Input.css";
import { FormControl } from "./components/FormControl";

export interface InputProps
extends Omit<DetailedHTMLProps<InputHTMLAttributes<HTMLInputElement>, HTMLInputElement>, "prefix" | "suffix"> {
Expand All @@ -10,22 +11,23 @@ export interface InputProps
suffix?: ReactNode;
disabled?: boolean;
state?: "default" | "error" | "warning";
stateText?: string;
hint?: string;
label?: string;
}

export const Input = forwardRef<HTMLInputElement, InputProps>(
({ className, wrapperClassName, prefix, suffix, disabled = false, state = "default", stateText, ...props }, ref) => {
(
{ className, wrapperClassName, prefix, suffix, disabled = false, state = "default", hint, label, ...props },
ref,
) => {
return (
<div className={twJoin("bbn-input", wrapperClassName)}>
<FormControl label={label} hint={hint} state={state} wrapperClassName={wrapperClassName}>
<div className={twJoin("bbn-input-wrapper", disabled && "bbn-input-disabled", `bbn-input-${state}`)}>
{prefix && <div className="bbn-input-prefix">{prefix}</div>}
<input ref={ref} className={twJoin("bbn-input-field", className)} disabled={disabled} {...props} />
{suffix && <div className="bbn-input-suffix">{suffix}</div>}
</div>
{stateText && (
<span className={twJoin("bbn-input-state-text", `bbn-input-state-text-${state}`)}>{stateText}</span>
)}
</div>
</FormControl>
);
},
);
Expand Down
12 changes: 12 additions & 0 deletions src/components/Form/Select.css
Original file line number Diff line number Diff line change
Expand Up @@ -33,4 +33,16 @@
&-disabled {
@apply pointer-events-none cursor-not-allowed opacity-50;
}

&-error {
@apply border-error-main;
}

&-warning {
@apply border-warning-main;
}

&-success {
@apply border-success-main;
}
}
13 changes: 13 additions & 0 deletions src/components/Form/Select.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -85,3 +85,16 @@ export const CustomSelectedDisplay: Story = {
renderSelectedOption: (option) => `Showing ${option.value}`,
},
};

export const WithError: Story = {
args: {
options: [
{ value: "active", label: "Active" },
{ value: "inactive", label: "Inactive" },
{ value: "pending", label: "Pending" },
],
placeholder: "Select status",
state: "error",
hint: "",
},
};
81 changes: 46 additions & 35 deletions src/components/Form/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { Popover } from "@/components/Popover";
import { useControlledState } from "@/hooks/useControlledState";
import "./Select.css";
import { useResizeObserver } from "@/hooks/useResizeObserver";
import { FormControl } from "./components/FormControl";

export type Value = string | number;

Expand All @@ -36,12 +37,16 @@ export interface SelectProps {
className?: string;
optionClassName?: string;
popoverClassName?: string;
wrapperClassName?: string;
state?: "default" | "error" | "warning";
hint?: string;
onSelect?: (value: Value) => void;
onOpen?: () => void;
onClose?: () => void;
onFocus?: () => void;
onBlur?: () => void;
renderSelectedOption?: (option: Option) => ReactNode;
label?: string;
}

const defaultOptionRenderer = (option: Option) => option.label;
Expand All @@ -51,6 +56,7 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
{
disabled,
className,
wrapperClassName,
value,
defaultValue,
placeholder = "Select option",
Expand All @@ -63,6 +69,9 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
onSelect,
onClose,
renderSelectedOption = defaultOptionRenderer,
state = "default",
hint,
label,
...props
},
ref,
Expand Down Expand Up @@ -107,42 +116,44 @@ export const Select = forwardRef<HTMLDivElement, SelectProps>(
}, [isOpen, disabled, setIsOpen]);

return (
<>
<div
ref={anchorEl}
className={twJoin("bbn-select", disabled && "bbn-select-disabled", className)}
onClick={handleClick}
tabIndex={disabled ? -1 : 0}
{...props}
>
<span>{selectedOption ? renderSelectedOption(selectedOption) : placeholder}</span>
<RiArrowDownSLine className={twJoin("bbn-select-icon", isOpen && "bbn-select-icon-open")} size={20} />
<FormControl label={label} hint={hint} state={state} wrapperClassName={wrapperClassName}>
<div className={twJoin("bbn-select-container")}>
<div
ref={anchorEl}
className={twJoin("bbn-select", disabled && "bbn-select-disabled", `bbn-select-${state}`, className)}
onClick={handleClick}
tabIndex={disabled ? -1 : 0}
{...props}
>
<span>{selectedOption ? renderSelectedOption(selectedOption) : placeholder}</span>
<RiArrowDownSLine className={twJoin("bbn-select-icon", isOpen && "bbn-select-icon-open")} size={20} />
</div>

<Popover
anchorEl={anchorEl.current}
className={twJoin("bbn-select-menu custom-scrollbar", popoverClassName)}
open={isOpen && !disabled}
onClickOutside={handleClose}
offset={[0, 4]}
placement="bottom-start"
style={{ width }}
>
{options.map((option) => (
<div
key={option.value}
className={twJoin(
"bbn-select-option",
selectedOption?.value === option.value && "bbn-select-option-selected",
optionClassName,
)}
onClick={() => handleSelect(option)}
>
{option.label}
</div>
))}
</Popover>
</div>

<Popover
anchorEl={anchorEl.current}
className={twJoin("bbn-select-menu custom-scrollbar", popoverClassName)}
open={isOpen && !disabled}
onClickOutside={handleClose}
offset={[0, 4]} // set offset to 4px on y axis
placement="bottom-start"
style={{ width }}
>
{options.map((option) => (
<div
key={option.value}
className={twJoin(
"bbn-select-option",
selectedOption?.value === option.value && "bbn-select-option-selected",
optionClassName,
)}
onClick={() => handleSelect(option)}
>
{option.label}
</div>
))}
</Popover>
</>
</FormControl>
);
},
);
Expand Down
19 changes: 19 additions & 0 deletions src/components/Form/components/FormControl.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
.bbn-form-control {
@apply flex flex-col;

&-hint {
@apply mt-1 text-sm;

&.bbn-form-control-hint-error {
@apply text-error-main;
}

&.bbn-form-control-hint-warning {
@apply text-warning-main;
}

&.bbn-form-control-hint-success {
@apply text-success-main;
}
}
}
30 changes: 30 additions & 0 deletions src/components/Form/components/FormControl.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { type PropsWithChildren } from "react";
import { twJoin } from "tailwind-merge";
import "./FormControl.css";

export interface FormControlProps extends PropsWithChildren {
label?: string;
hint?: string;
state?: "default" | "error" | "warning" | "success";
className?: string;
wrapperClassName?: string;
}

export function FormControl({
children,
label,
hint,
state = "default",
className,
wrapperClassName,
}: FormControlProps) {
return (
<div className={twJoin("bbn-form-control", wrapperClassName)}>
{label && <label className="bbn-form-control-label mb-2 block text-sm text-primary-light">{label}</label>}

<div className={className}>{children}</div>

{hint && <span className={twJoin("bbn-form-control-hint", `bbn-form-control-hint-${state}`)}>{hint}</span>}
</div>
);
}
62 changes: 62 additions & 0 deletions src/components/Table/Table.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,3 +119,65 @@ export const Default: Story = {
);
},
};

export const WithoutRowSelect: Story = {
render: () => {
const [tableData, setTableData] = useState(data.slice(0, 3));
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);

const handleLoadMore = async () => {
setLoading(true);
await new Promise((resolve) => setTimeout(resolve, 1000));

const nextItems = data.slice(tableData.length, tableData.length + 3);
setTableData((prev) => [...prev, ...nextItems]);
setHasMore(tableData.length + nextItems.length < data.length);
setLoading(false);
};

return (
<div className="h-[150px]">
<Table
data={tableData}
hasMore={hasMore}
loading={loading}
onLoadMore={handleLoadMore}
columns={[
{
key: "name",
header: "Finality Provider",
render: (_, row) => (
<div className="flex items-center gap-2">
<Avatar size="small" url={row.icon} alt={row.name} />
<span className="text-primary-light">{row.name}</span>
</div>
),
sorter: (a, b) => a.name.localeCompare(b.name),
},
{
key: "status",
header: "Status",
},
{
key: "btcPk",
header: "BTC PK",
},
{
key: "totalDelegation",
header: "Total Delegation",
render: (value) => `${value} sBTC`,
sorter: (a, b) => a.totalDelegation - b.totalDelegation,
},
{
key: "commission",
header: "Commission",
render: (value) => `${value}%`,
sorter: (a, b) => a.commission - b.commission,
},
]}
/>
</div>
);
},
};
5 changes: 3 additions & 2 deletions src/components/Table/Table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,10 @@ export function Table<T extends { id: string | number }>({
};

const handleRowSelect = (row: T) => {
if (!onRowSelect) return;
if (selectedRow === row.id) return;
setSelectedRow(row.id);
onRowSelect?.(row);
onRowSelect(row);
};

const handleColumnSort = (columnKey: string, sorter?: (a: T, b: T) => number) => {
Expand Down Expand Up @@ -127,7 +128,7 @@ export function Table<T extends { id: string | number }>({
{sortedData.map((row) => (
<tr
key={row.id}
className={twJoin(selectedRow === row.id && "selected", "cursor-pointer")}
className={twJoin(selectedRow === row.id && "selected", onRowSelect && "cursor-pointer")}
onClick={() => handleRowSelect(row)}
>
{columns.map((column) => (
Expand Down

0 comments on commit a8049c4

Please sign in to comment.