diff --git a/.changeset/blue-pumpkins-complain.md b/.changeset/blue-pumpkins-complain.md new file mode 100644 index 0000000..3863d2f --- /dev/null +++ b/.changeset/blue-pumpkins-complain.md @@ -0,0 +1,5 @@ +--- +"@babylonlabs-io/bbn-core-ui": minor +--- + +add input and select components diff --git a/package-lock.json b/package-lock.json index 5da55f1..2501c14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,16 @@ { "name": "@babylonlabs-io/bbn-core-ui", - "version": "0.2.0", + "version": "0.3.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@babylonlabs-io/bbn-core-ui", - "version": "0.2.0", + "version": "0.3.1", + "dependencies": { + "@popperjs/core": "^2.11.8", + "react-popper": "^2.3.0" + }, "devDependencies": { "@changesets/cli": "^2.27.9", "@chromatic-com/storybook": "^3.2.2", @@ -1819,6 +1823,16 @@ "node": ">=14" } }, + "node_modules/@popperjs/core": { + "version": "2.11.8", + "resolved": "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz", + "integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/popperjs" + } + }, "node_modules/@rollup/pluginutils": { "version": "5.1.3", "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.1.3.tgz", @@ -6959,6 +6973,12 @@ "react": "^18.3.1" } }, + "node_modules/react-fast-compare": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" + }, "node_modules/react-icons": { "version": "5.3.0", "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.3.0.tgz", @@ -6975,6 +6995,21 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true }, + "node_modules/react-popper": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/react-popper/-/react-popper-2.3.0.tgz", + "integrity": "sha512-e1hj8lL3uM+sgSR4Lxzn5h1GxBlpa4CQz0XLF8kx4MDrDRWY0Ena4c97PUeSX9i5W3UAfDP0z0FXCTQkoXUl3Q==", + "license": "MIT", + "dependencies": { + "react-fast-compare": "^3.0.1", + "warning": "^4.0.2" + }, + "peerDependencies": { + "@popperjs/core": "^2.0.0", + "react": "^16.8.0 || ^17 || ^18", + "react-dom": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/react-refresh": { "version": "0.14.2", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz", @@ -8173,6 +8208,15 @@ "integrity": "sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==", "dev": true }, + "node_modules/warning": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/warning/-/warning-4.0.3.tgz", + "integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/webpack-virtual-modules": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz", diff --git a/package.json b/package.json index 705aeec..9d33b23 100644 --- a/package.json +++ b/package.json @@ -85,5 +85,9 @@ "extends": [ "plugin:storybook/recommended" ] + }, + "dependencies": { + "@popperjs/core": "^2.11.8", + "react-popper": "^2.3.0" } } diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index c0b384c..43fbedc 100644 --- a/src/components/Dialog/Dialog.stories.tsx +++ b/src/components/Dialog/Dialog.stories.tsx @@ -5,7 +5,7 @@ import { Dialog, DialogFooter, DialogBody, DialogHeader } from "./index"; import { ScrollLocker } from "@/context/Dialog.context"; import { Button } from "@/components/Button"; -import { Checkbox } from "@/components/Input"; +import { Checkbox } from "@/components/Form"; import { Text } from "@/components/Text"; import { Heading } from "@/index"; diff --git a/src/components/Dialog/MobileDialog.stories.tsx b/src/components/Dialog/MobileDialog.stories.tsx index 0c20f44..1cb70c0 100644 --- a/src/components/Dialog/MobileDialog.stories.tsx +++ b/src/components/Dialog/MobileDialog.stories.tsx @@ -5,7 +5,7 @@ import { MobileDialog, DialogFooter, DialogBody, DialogHeader } from "./index"; import { ScrollLocker } from "@/context/Dialog.context"; import { Button } from "@/components/Button"; -import { Checkbox } from "@/components/Input"; +import { Checkbox } from "@/components/Form"; import { Text } from "@/components/Text"; const meta: Meta = { diff --git a/src/components/Input/Checkbox.stories.tsx b/src/components/Form/Checkbox.stories.tsx similarity index 100% rename from src/components/Input/Checkbox.stories.tsx rename to src/components/Form/Checkbox.stories.tsx diff --git a/src/components/Input/Checkbox.tsx b/src/components/Form/Checkbox.tsx similarity index 100% rename from src/components/Input/Checkbox.tsx rename to src/components/Form/Checkbox.tsx diff --git a/src/components/Form/Input.css b/src/components/Form/Input.css new file mode 100644 index 0000000..0b540dd --- /dev/null +++ b/src/components/Form/Input.css @@ -0,0 +1,55 @@ +.bbn-input { + @apply relative flex flex-col; + + &-wrapper { + @apply flex items-center rounded border border-primary-light/20 bg-secondary-contrast px-4 py-2 text-primary-light transition-colors; + + &:focus-within { + @apply border-primary-light; + } + + &.bbn-input-error { + @apply border-error-main; + } + + &.bbn-input-warning { + @apply border-warning-main; + } + + &.bbn-input-success { + @apply border-success-main; + } + + &.bbn-input-disabled { + @apply opacity-50 pointer-events-none; + } + } + + &-field { + @apply w-full bg-transparent text-sm outline-none placeholder:text-primary-light/50; + } + + &-suffix { + @apply ml-2 flex items-center text-primary-light/50; + } + + &-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; + } +} \ No newline at end of file diff --git a/src/components/Form/Input.stories.tsx b/src/components/Form/Input.stories.tsx new file mode 100644 index 0000000..98f26bd --- /dev/null +++ b/src/components/Form/Input.stories.tsx @@ -0,0 +1,74 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { RiSearchLine } from "react-icons/ri"; +import { useState } from "react"; + +import { Loader } from "@/components/Loader"; + +import { Input } from "./Input"; + +const meta: Meta = { + component: Input, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + placeholder: "Default input", + }, +}; + +export const Disabled: Story = { + args: { + placeholder: "Disabled input", + disabled: true, + }, +}; + +export const Error: Story = { + args: { + placeholder: "Input with error", + state: "error", + stateText: "This field is required", + }, +}; + +export const WithSuffix: Story = { + args: { + placeholder: "Search...", + suffix: , + }, +}; + +export const WithPrefix: Story = { + args: { + placeholder: "Amount", + prefix: "$", + }, +}; + +export const LoadingWithInteraction: Story = { + render: () => { + const [isLoading, setIsLoading] = useState(false); + + const handleSearch = () => { + setIsLoading(true); + setTimeout(() => setIsLoading(false), 2000); + }; + + return ( + + {isLoading ? : } + + } + disabled={isLoading} + /> + ); + }, +}; diff --git a/src/components/Form/Input.tsx b/src/components/Form/Input.tsx new file mode 100644 index 0000000..a1deabb --- /dev/null +++ b/src/components/Form/Input.tsx @@ -0,0 +1,33 @@ +import { forwardRef, type DetailedHTMLProps, type InputHTMLAttributes, type ReactNode } from "react"; +import { twJoin } from "tailwind-merge"; +import "./Input.css"; + +export interface InputProps + extends Omit, HTMLInputElement>, "prefix" | "suffix"> { + className?: string; + wrapperClassName?: string; + prefix?: ReactNode; + suffix?: ReactNode; + disabled?: boolean; + state?: "default" | "error" | "warning"; + stateText?: string; +} + +export const Input = forwardRef( + ({ className, wrapperClassName, prefix, suffix, disabled = false, state = "default", stateText, ...props }, ref) => { + return ( +
+
+ {prefix &&
{prefix}
} + + {suffix &&
{suffix}
} +
+ {stateText && ( + {stateText} + )} +
+ ); + }, +); + +Input.displayName = "Input"; diff --git a/src/components/Input/Radio.stories.tsx b/src/components/Form/Radio.stories.tsx similarity index 100% rename from src/components/Input/Radio.stories.tsx rename to src/components/Form/Radio.stories.tsx diff --git a/src/components/Input/Radio.tsx b/src/components/Form/Radio.tsx similarity index 100% rename from src/components/Input/Radio.tsx rename to src/components/Form/Radio.tsx diff --git a/src/components/Form/Select.css b/src/components/Form/Select.css new file mode 100644 index 0000000..8e840f0 --- /dev/null +++ b/src/components/Form/Select.css @@ -0,0 +1,36 @@ +.bbn-select { + @apply relative flex w-full cursor-pointer items-center justify-between rounded border border-primary-light bg-secondary-contrast px-4 py-2 text-sm text-primary-light outline-none transition-all; + width: var(--select-width, auto); + + &:focus-visible { + @apply border-primary-light ring-1 ring-primary-light; + } + + &-icon { + @apply ml-2 text-primary-light transition-transform; + + &-open { + @apply rotate-180; + } + } + + &-menu { + @apply z-50 max-h-60 overflow-auto rounded border border-primary-light/20 bg-secondary-contrast py-1 shadow-lg; + } + + &-option { + @apply cursor-pointer px-4 py-3 text-sm text-primary-light transition-colors hover:bg-primary-light/10; + + &-selected { + @apply border-primary-light bg-primary-light/10; + } + + &-highlighted { + @apply bg-primary-light/20; + } + } + + &-disabled { + @apply pointer-events-none cursor-not-allowed opacity-50; + } +} diff --git a/src/components/Form/Select.stories.tsx b/src/components/Form/Select.stories.tsx new file mode 100644 index 0000000..ef60b6a --- /dev/null +++ b/src/components/Form/Select.stories.tsx @@ -0,0 +1,87 @@ +import type { Meta, StoryObj } from "@storybook/react"; + +import { Select } from "./Select"; + +const meta: Meta = { + component: Select, + tags: ["autodocs"], +}; + +export default meta; + +type Story = StoryObj; + +const options = [ + { value: "active", label: "Active" }, + { value: "inactive", label: "Inactive" }, + { value: "pending", label: "Pending" }, +]; + +export const Default: Story = { + args: { + options, + placeholder: "Select status", + onSelect: console.log, + }, +}; + +export const Controlled: Story = { + args: { + defaultValue: "active", + options, + placeholder: "Select status", + }, +}; + +export const CenterAligned: Story = { + args: { + options, + placeholder: "Select status", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const RightAligned: Story = { + args: { + placeholder: "Select status", + options, + }, + render: (args) => ( +
+
Title
+