Add UI input & textarea

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
Signed-off-by: Sacha Al Himdani <sacha@getprobo.com>
This commit is contained in:
Jonathan
2025-05-21 11:02:49 +02:00
committed by Sacha Al Himdani
parent a86181c83d
commit 15cf471454
35 changed files with 3094 additions and 14933 deletions

View File

@@ -11,6 +11,6 @@ type Story = StoryObj<typeof Avatar>;
export const Default: Story = {
args: {
fullName: "John Doe",
name: "John Doe",
},
};

View File

@@ -1,6 +1,7 @@
import clsx from "clsx";
import { Link } from "react-router";
import { IconChevronRight } from "../Icons";
import { Fragment } from "react/jsx-runtime";
type Props = {
items: (ItemProps | string)[];
@@ -15,15 +16,14 @@ type ItemProps = {
export function Breadcrumb({ items }: Props) {
return (
<div className="flex items-center gap-[6px] text-txt-tertiary">
{items.map((item, index) => (
<>
{index > 0 && <IconChevronRight size={12} />}
{items.map((item, k) => (
<Fragment key={k}>
{k > 0 && <IconChevronRight size={12} />}
<BreadcrumbItem
key={index}
{...(typeof item === "string" ? { label: item } : item)}
active={index === items.length - 1}
active={k === items.length - 1}
/>
</>
</Fragment>
))}
</div>
);

View File

@@ -4,7 +4,6 @@ import type {
FC,
PropsWithChildren,
} from "react";
import { forwardRef } from "react";
import { Link } from "react-router";
import { tv, type VariantProps } from "tailwind-variants";
@@ -56,28 +55,25 @@ type Props = PropsWithChildren<
| AnchorHTMLAttributes<HTMLAnchorElement>
);
export const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, Props>(
(props, ref) => {
const Component = props.to ? Link : "button";
const {
icon: IconComponent,
iconAfter: IconAfterComponent,
children,
onClick,
...componentProps
} = props;
return (
<Component
{...componentProps}
onClick={onClick}
// @ts-expect-error Component is too dynamic
ref={ref}
className={button({ ...props, empty: !children })}
>
{IconComponent && <IconComponent size={16} />}
{children}
{IconAfterComponent && <IconAfterComponent size={16} />}
</Component>
);
},
);
export const Button = (props: Props) => {
const Component = props.to ? Link : "button";
const {
icon: IconComponent,
iconAfter: IconAfterComponent,
children,
onClick,
...componentProps
} = props;
return (
// @ts-expect-error Component is too dynamic
<Component
{...componentProps}
onClick={onClick}
className={button({ ...props, empty: !children })}
>
{IconComponent && <IconComponent size={16} />}
{children}
{IconAfterComponent && <IconAfterComponent size={16} />}
</Component>
);
};

View File

@@ -9,10 +9,4 @@ export default {
type Story = StoryObj<typeof Input>;
export const Default: Story = {
args: {
label: "Label",
name: "id",
help: "e.g. This is a hint",
},
};
export const Default: Story = {};

View File

@@ -1,54 +1,12 @@
import type { InputHTMLAttributes } from "react";
import { tv } from "tailwind-variants";
type Props = {
label?: string;
help?: string;
name?: string;
placeholder?: string;
type?: string;
required?: boolean;
};
type Props = InputHTMLAttributes<HTMLInputElement>;
const input = tv({
slots: {
base: "flex flex-col",
label: "text-sm font-medium text-txt-primary mb-[6px]",
input: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3",
help: "text-xs font-semibold text-txt-tertiary mt-1",
},
export const input = tv({
base: "py-[6px] bg-secondary border border-border-mid rounded-[10px] hover:border-border-strong focus:shadow-focus text-sm px-3",
});
const {
base: baseClass,
label: labelClass,
input: inputClass,
help: helpClass,
} = input();
export function Input({
label,
help,
name,
placeholder,
type,
required,
}: Props) {
return (
<div className={baseClass()}>
{label && (
<label htmlFor={name} className={labelClass()}>
{label}
</label>
)}
<input
name={name}
id={name}
className={inputClass()}
placeholder={placeholder}
type={type}
required={required}
/>
{help && <span className={helpClass()}>{help}</span>}
</div>
);
export function Input(props: Props) {
return <input className={input(props)} {...props} />;
}

View File

@@ -0,0 +1,12 @@
import { Label } from "./Label";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Form/Label",
component: Label,
argTypes: {},
} satisfies Meta<typeof Label>;
type Story = StoryObj<typeof Label>;
export const Default: Story = {};

View File

@@ -0,0 +1,14 @@
import type { HTMLAttributes } from "react";
import { tv } from "tailwind-variants";
type Props = HTMLAttributes<HTMLLabelElement> & {
htmlFor?: string;
};
const label = tv({
base: "text-sm font-medium text-txt-primary",
});
export function Label({ ...props }: Props) {
return <label {...props} className={label(props)} />;
}

View File

@@ -0,0 +1,31 @@
import { Select, Option } from "./Select";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Form/Select",
component: Select,
argTypes: {},
} satisfies Meta<typeof Select>;
type Story = StoryObj<typeof Select>;
export const Default: Story = {
args: {
placeholder: "Select an option",
children: (
<>
<Option value="apple">Apple</Option>
<Option value="banana">Banana</Option>
<Option value="blueberry">Blueberry</Option>
<Option value="grapes">Grapes</Option>
<Option value="pineapple">Pineapple</Option>
</>
),
},
};
export const NoChildren: Story = {
args: {
placeholder: "Select an option",
},
};

View File

@@ -0,0 +1,94 @@
import {
Root,
Trigger,
Value,
Icon,
Portal,
Content,
Viewport,
Item,
ItemText,
} from "@radix-ui/react-select";
import * as ScrollArea from "@radix-ui/react-scroll-area";
import { input } from "../Input/Input.tsx";
import { IconChevronGrabberVertical } from "../Icons/IconChevronGrabberVertical.tsx";
import { tv } from "tailwind-variants";
import type { ComponentProps, PropsWithChildren } from "react";
type Props = PropsWithChildren<
{
placeholder?: string;
onChange?: (s: string) => void;
empty?: boolean;
} & Omit<ComponentProps<typeof Trigger>, "onChange">
>;
const select = tv({
slots: {
trigger: input({
className:
"flex justify-between items-center gap-4 data-placeholder:text-txt-tertiary w-full whitespace-nowrap",
}),
content:
"z-100 shadow-mid rounded-[10px] bg-level-1 p-1 animate-in fade-in slide-in-from-top-2 overflow-y-auto overflow-y-auto",
option: "flex items-center gap-2 h-8 text-sm font-medium text-txt-primary hover:bg-tertiary-hover active:bg-tertiary-pressed cursor-pointer px-[10px]",
},
variants: {
empty: {
true: {
trigger: "border-dashed pointer-events-none",
},
},
},
});
const { trigger, option, content } = select();
export function Select({
placeholder,
children,
onChange,
empty,
...props
}: Props) {
return (
<Root onValueChange={onChange}>
<Trigger className={trigger({ ...props, empty })}>
<div className="text-ellipsis overflow-hidden">
<Value placeholder={placeholder} />
</div>
<Icon className="SelectIcon">
<IconChevronGrabberVertical size={16} />
</Icon>
</Trigger>
<Portal>
<Content
className={content()}
position="popper"
sideOffset={5}
style={{
minWidth: "var(--radix-select-trigger-width)",
maxHeight:
"var(--radix-select-content-available-height)",
}}
>
<ScrollArea.Root className="ScrollAreaRoot" type="auto">
<Viewport asChild>
<ScrollArea.Viewport className="ScrollAreaViewport">
{children}
</ScrollArea.Viewport>
</Viewport>
</ScrollArea.Root>
</Content>
</Portal>
</Root>
);
}
export function Option({ children, ...props }: ComponentProps<typeof Item>) {
return (
<Item {...props} className={option(props)}>
<ItemText>{children}</ItemText>
</Item>
);
}

View File

@@ -0,0 +1,12 @@
import { Textarea } from "./Textarea";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Atoms/Form/Textarea",
component: Textarea,
argTypes: {},
} satisfies Meta<typeof Textarea>;
type Story = StoryObj<typeof Textarea>;
export const Default: Story = {};

View File

@@ -0,0 +1,15 @@
import type { InputHTMLAttributes } from "react";
import { tv } from "tailwind-variants";
import { input } from "../Input/Input";
type Props = InputHTMLAttributes<HTMLTextAreaElement>;
export const textarea = tv({
base: input({ class: "min-h-20" }),
});
console.log(input({ class: "min-h-20" }));
export function Textarea(props: Props) {
return <textarea className={textarea(props)} {...props} />;
}

View File

@@ -6,27 +6,39 @@ import {
Content,
Title,
Close,
Description,
} from "@radix-ui/react-dialog";
import { IconCrossLargeX } from "../../Atoms/Icons";
import type { ReactNode } from "react";
import type { HTMLAttributes, ReactNode } from "react";
import { Button } from "../../Atoms/Button/Button";
import { useTranslate } from "@probo/i18n";
import clsx from "clsx";
type Props = {
onClose: () => void;
trigger: ReactNode;
title: ReactNode;
children?: ReactNode;
open?: boolean;
};
export function Dialog({ onClose, trigger, title, children }: Props) {
export function Dialog({ onClose, trigger, title, children, open }: Props) {
const onOpenChange = (open: boolean) => {
if (!open) {
onClose();
}
};
return (
<Root>
<Root open={open} onOpenChange={onOpenChange}>
<Trigger asChild>{trigger}</Trigger>
<Portal>
<Overlay className="fixed inset-0 z-50 bg-dialog/40 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
<div className="fixed grid place-items-center inset-0 z-50">
<Content className="bg-level-2 rounded-2xl max-w-5xl w-[95%]">
<Content
aria-describedby={undefined}
className="bg-level-2 rounded-2xl max-w-5xl w-[95%]"
>
<div className="flex justify-between items-center p-3 border-b border-b-border-low">
<Title className="text-sm font-medium">
{title}
@@ -58,15 +70,14 @@ export function DialogFooter({ children }: { children?: ReactNode }) {
);
}
export function DialogContent({ children }: { children: ReactNode }) {
export function DialogContent(props: HTMLAttributes<HTMLDivElement>) {
return (
<div
className="overflow-y-auto"
{...props}
className={clsx("overflow-y-auto", props.className)}
style={{
maxHeight: "min(640px, calc(100vh - 140px))",
}}
>
{children}
</div>
/>
);
}

View File

@@ -0,0 +1,18 @@
import { Field } from "./Field.tsx";
import type { Meta, StoryObj } from "@storybook/react";
export default {
title: "Molecules/Field",
component: Field,
argTypes: {},
} satisfies Meta<typeof Field>;
type Story = StoryObj<typeof Field>;
export const Default: Story = {
args: {
label: "Label",
name: "id",
help: "e.g. This is a hint",
},
};

View File

@@ -0,0 +1,51 @@
import { tv } from "tailwind-variants";
import { Label } from "../../Atoms/Label/Label";
import { Input } from "../../Atoms/Input/Input";
import { Textarea } from "../../Atoms/Textarea/Textarea";
type Props = {
label?: string;
help?: string;
name?: string;
placeholder?: string;
type?: string;
required?: boolean;
};
const field = tv({
slots: {
base: "flex flex-col",
label: "mb-[6px]",
help: "text-xs font-semibold text-txt-tertiary mt-1",
},
});
const { base: baseClass, label: labelClass, help: helpClass } = field();
export function Field({
label,
help,
name,
placeholder,
type,
required,
}: Props) {
const Comp = type === "textarea" ? Textarea : Input;
return (
<div className={baseClass()}>
{label && (
<Label htmlFor={name} className={labelClass()}>
{label}
</Label>
)}
<Comp
name={name}
id={name}
placeholder={placeholder}
type={type}
required={required}
/>
{help && <span className={helpClass()}>{help}</span>}
</div>
);
}

View File

@@ -18,14 +18,14 @@ export function UserDropdown({ fullName, children, email }: Props) {
className="w-60"
toggle={
<Button variant="tertiary">
<Avatar fullName={fullName} />
<Avatar name={fullName} />
<span>{fullName}</span>
<IconChevronDown size={16} />
</Button>
}
>
<div className="flex gap-2 items-center">
<Avatar fullName={fullName} size="l" />
<Avatar name={fullName} size="l" />
<div>
<p className="text-sm font-medium text-txt-primary">
{fullName}

View File

@@ -20,7 +20,10 @@ export {
DropdownItem,
} from "./Atoms/Dropdown/Dropdown";
export { Avatar } from "./Atoms/Avatar/Avatar";
export { Input } from "./Atoms/Input/Input";
export { Field } from "./Molecules/Field/Field.tsx";
export { Input } from "./Atoms/Input/Input.tsx";
export { Select, Option } from "./Atoms/Select/Select.tsx";
export { Label } from "./Atoms/Label/Label";
// Molecules
export {

View File

@@ -1,10 +0,0 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./theme.css";
import App from "./App.tsx";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>,
);