From e560b94dade2559630e9c1c068c338e0e4ded03f Mon Sep 17 00:00:00 2001 From: jeremy-babylonlabs Date: Fri, 6 Dec 2024 18:09:00 +0800 Subject: [PATCH] add popover and update props --- src/components/Dialog/Dialog.stories.tsx | 2 +- .../Dialog/MobileDialog.stories.tsx | 2 +- .../{Inputs => Form}/Checkbox.stories.tsx | 0 src/components/{Inputs => Form}/Checkbox.tsx | 0 src/components/Form/Input.css | 55 +++++++ .../{Inputs => Form}/Input.stories.tsx | 56 ++++--- src/components/Form/Input.tsx | 47 ++++++ .../{Inputs => Form}/Radio.stories.tsx | 0 src/components/{Inputs => Form}/Radio.tsx | 0 src/components/Form/Select.css | 45 ++++++ src/components/Form/Select.stories.tsx | 102 ++++++++++++ src/components/Form/Select.tsx | 145 ++++++++++++++++++ .../{Inputs => Form}/components/Toggle.css | 0 .../{Inputs => Form}/components/Toggle.tsx | 0 src/components/{Inputs => Form}/index.tsx | 0 src/components/Inputs/Input.css | 15 -- src/components/Inputs/Input.tsx | 37 ----- src/components/Inputs/Select.css | 31 ---- src/components/Inputs/Select.stories.tsx | 54 ------- src/components/Inputs/Select.tsx | 65 -------- src/components/Popover/Popover.css | 3 + src/components/Popover/Popover.tsx | 32 ++++ src/components/Popover/index.ts | 1 + src/hooks/useAdaptivePosition.ts | 72 +++++++++ src/hooks/useClickOutside.ts | 27 ++++ src/hooks/useKeyboardNavigation.ts | 88 +++++++++++ src/index.tsx | 3 +- 27 files changed, 655 insertions(+), 227 deletions(-) rename src/components/{Inputs => Form}/Checkbox.stories.tsx (100%) rename src/components/{Inputs => Form}/Checkbox.tsx (100%) create mode 100644 src/components/Form/Input.css rename src/components/{Inputs => Form}/Input.stories.tsx (55%) create mode 100644 src/components/Form/Input.tsx rename src/components/{Inputs => Form}/Radio.stories.tsx (100%) rename src/components/{Inputs => Form}/Radio.tsx (100%) create mode 100644 src/components/Form/Select.css create mode 100644 src/components/Form/Select.stories.tsx create mode 100644 src/components/Form/Select.tsx rename src/components/{Inputs => Form}/components/Toggle.css (100%) rename src/components/{Inputs => Form}/components/Toggle.tsx (100%) rename src/components/{Inputs => Form}/index.tsx (100%) delete mode 100644 src/components/Inputs/Input.css delete mode 100644 src/components/Inputs/Input.tsx delete mode 100644 src/components/Inputs/Select.css delete mode 100644 src/components/Inputs/Select.stories.tsx delete mode 100644 src/components/Inputs/Select.tsx create mode 100644 src/components/Popover/Popover.css create mode 100644 src/components/Popover/Popover.tsx create mode 100644 src/components/Popover/index.ts create mode 100644 src/hooks/useAdaptivePosition.ts create mode 100644 src/hooks/useClickOutside.ts create mode 100644 src/hooks/useKeyboardNavigation.ts diff --git a/src/components/Dialog/Dialog.stories.tsx b/src/components/Dialog/Dialog.stories.tsx index 7e1838e..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/Inputs"; +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 fea8e6e..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/Inputs"; +import { Checkbox } from "@/components/Form"; import { Text } from "@/components/Text"; const meta: Meta = { diff --git a/src/components/Inputs/Checkbox.stories.tsx b/src/components/Form/Checkbox.stories.tsx similarity index 100% rename from src/components/Inputs/Checkbox.stories.tsx rename to src/components/Form/Checkbox.stories.tsx diff --git a/src/components/Inputs/Checkbox.tsx b/src/components/Form/Checkbox.tsx similarity index 100% rename from src/components/Inputs/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/Inputs/Input.stories.tsx b/src/components/Form/Input.stories.tsx similarity index 55% rename from src/components/Inputs/Input.stories.tsx rename to src/components/Form/Input.stories.tsx index e672e32..8180ba6 100644 --- a/src/components/Inputs/Input.stories.tsx +++ b/src/components/Form/Input.stories.tsx @@ -2,6 +2,8 @@ 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 = { @@ -11,7 +13,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { @@ -19,26 +21,38 @@ export const Default: Story = { }, }; -export const WithSuffix: Story = { +export const Disabled: Story = { args: { - placeholder: "Search by Name or Public Key", - suffix: , + placeholder: "Disabled input", + disabled: true, }, }; -export const WithClickableSuffix: Story = { +export const Error: Story = { args: { - placeholder: "Click the search icon", - suffix: , - onSuffixClick: () => alert("Search clicked!"), + placeholder: "Input with error", + state: { + type: "error", + text: "This field is required", + }, }, }; -export const Loading: Story = { +export const WithSuffix: Story = { args: { - placeholder: "Loading state", - suffix: , - isLoading: true, + placeholder: "Search...", + decorators: { + suffix: , + }, + }, +}; + +export const WithPrefix: Story = { + args: { + placeholder: "Amount", + decorators: { + prefix: "$", + }, }, }; @@ -54,17 +68,15 @@ export const LoadingWithInteraction: Story = { return ( } - isLoading={isLoading} - onSuffixClick={handleSearch} + decorators={{ + suffix: ( + + ), + }} + disabled={isLoading} /> ); }, }; - -export const Disabled: Story = { - args: { - placeholder: "Disabled input", - disabled: true, - }, -}; diff --git a/src/components/Form/Input.tsx b/src/components/Form/Input.tsx new file mode 100644 index 0000000..f61bc6f --- /dev/null +++ b/src/components/Form/Input.tsx @@ -0,0 +1,47 @@ +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; + decorators?: { + prefix?: ReactNode; + suffix?: ReactNode; + }; + disabled?: boolean; + state?: { + type?: "error" | "warning"; + text?: string; + render?: ReactNode; + }; +} + +export const Input = forwardRef( + ({ className, wrapperClassName, decorators, disabled = false, state, ...props }, ref) => { + const stateClass = + state?.type === "error" ? "bbn-input-error" : state?.type === "warning" ? "bbn-input-warning" : ""; + + const stateTextClass = + state?.type === "error" + ? "bbn-input-state-text-error" + : state?.type === "warning" + ? "bbn-input-state-text-warning" + : ""; + + return ( +
+
+ {decorators?.prefix &&
{decorators.prefix}
} + + {decorators?.suffix &&
{decorators.suffix}
} +
+ {state?.render ?? + (state?.text && {state.text})} +
+ ); + }, +); + +Input.displayName = "Input"; diff --git a/src/components/Inputs/Radio.stories.tsx b/src/components/Form/Radio.stories.tsx similarity index 100% rename from src/components/Inputs/Radio.stories.tsx rename to src/components/Form/Radio.stories.tsx diff --git a/src/components/Inputs/Radio.tsx b/src/components/Form/Radio.tsx similarity index 100% rename from src/components/Inputs/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..9c7f6a0 --- /dev/null +++ b/src/components/Form/Select.css @@ -0,0 +1,45 @@ +.bbn-select { + @apply relative; + width: var(--select-width, auto); + + &-trigger { + @apply 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 transition-all outline-none; + + &: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 mt-1 max-h-60 overflow-auto rounded border border-primary-light/20 bg-secondary-contrast py-1 shadow-lg; + min-width: max(var(--menu-width, var(--component-width)), var(--component-width)); + width: var(--menu-width, var(--component-width)); + position: var(--menu-position, absolute); + left: var(--menu-left, 0); + transform: var(--menu-transform, none); + } + + &-option { + @apply cursor-pointer px-4 py-3 text-sm text-primary-light transition-colors hover:bg-primary-light/10; + + &-selected { + @apply border-l-2 border-primary-light bg-primary-light/10; + } + + &-highlighted { + @apply bg-primary-light/20; + } + } + + &-disabled { + @apply cursor-not-allowed opacity-50 pointer-events-none; + } +} \ No newline at end of file diff --git a/src/components/Form/Select.stories.tsx b/src/components/Form/Select.stories.tsx new file mode 100644 index 0000000..25887e6 --- /dev/null +++ b/src/components/Form/Select.stories.tsx @@ -0,0 +1,102 @@ +import type { Meta, StoryObj } from "@storybook/react"; +import { useState } from "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", + }, +}; + +export const Controlled: Story = { + args: { + options, + placeholder: "Select status", + }, + render: (args) => { + const [value, setValue] = useState("active"); + + return ( + + + ), +}; + +export const LeftAligned: Story = { + args: { + options, + selectWidth: "200px", + menuWidth: "300px", + }, + render: (args) => ( +
+ +
+ ), +}; + +export const Disabled: Story = { + args: { + options, + placeholder: "Select status", + disabled: true, + }, +}; + +export const CustomSelectedDisplay: Story = { + args: { + options, + placeholder: "Select status", + renderSelected: (option) => `Showing ${option.value}`, + }, +}; diff --git a/src/components/Form/Select.tsx b/src/components/Form/Select.tsx new file mode 100644 index 0000000..d06bb91 --- /dev/null +++ b/src/components/Form/Select.tsx @@ -0,0 +1,145 @@ +import { + forwardRef, + useCallback, + useRef, + type DetailedHTMLProps, + type SelectHTMLAttributes, + type ReactNode, +} from "react"; +import { twJoin } from "tailwind-merge"; +import { RiArrowDownSLine } from "react-icons/ri"; +import { Popover } from "@/components/Popover"; +import { useControlledState } from "@/hooks/useControlledState"; +import { useKeyboardNavigation } from "@/hooks/useKeyboardNavigation"; +import { useAdaptivePosition } from "@/hooks/useAdaptivePosition"; +import "./Select.css"; + +export interface SelectOption { + value: string; + label: string; +} +export interface SelectProps + extends Omit, HTMLDivElement>, "value" | "onChange"> { + options: SelectOption[]; + open?: boolean; + defaultOpen?: boolean; + value?: string | number; + defaultValue?: string | number; + onChange?: (value: string | number) => void; + className?: string; + placeholder?: string; + selectWidth?: string; + menuWidth?: string; + disabled?: boolean; + renderSelected?: (option: SelectOption) => ReactNode; +} + +export const Select = forwardRef( + ( + { + className, + options, + value, + defaultValue, + onChange, + placeholder = "Select option", + open, + defaultOpen, + selectWidth, + menuWidth, + disabled, + renderSelected, + ...props + }, + ref, + ) => { + const triggerRef = useRef(null); + const [isOpen, setIsOpen] = useControlledState({ + value: open, + defaultValue: defaultOpen, + }); + + const [selectedValue, setSelectedValue] = useControlledState({ + value, + defaultValue, + onStateChange: onChange, + }); + + const selectedOption = options.find((option) => option.value === selectedValue); + + const handleSelect = useCallback( + (option: SelectOption) => { + setSelectedValue(option.value); + setIsOpen(false); + }, + [setSelectedValue, setIsOpen], + ); + + const handleClose = useCallback(() => { + setIsOpen(false); + }, [setIsOpen]); + + const { highlightedIndex, handleKeyDown } = useKeyboardNavigation({ + items: options, + isOpen, + disabled, + onOpen: () => !disabled && setIsOpen(true), + onClose: () => { + setIsOpen(false); + }, + onSelect: handleSelect, + }); + + const menuStyle = useAdaptivePosition({ + triggerRef, + isOpen, + width: menuWidth, + customVariables: { + "--component-width": triggerRef.current ? `${triggerRef.current.getBoundingClientRect().width}px` : "auto", + "--menu-width": menuWidth || "var(--component-width)", + }, + }); + + return ( +
+
!disabled && setIsOpen(!isOpen)} + onKeyDown={handleKeyDown} + tabIndex={disabled ? -1 : 0} + {...props} + > + + {selectedOption ? (renderSelected ? renderSelected(selectedOption) : selectedOption.label) : placeholder} + + +
+ + + {options.map((option, index) => ( +
handleSelect(option)} + > + {option.label} +
+ ))} +
+
+ ); + }, +); + +Select.displayName = "Select"; diff --git a/src/components/Inputs/components/Toggle.css b/src/components/Form/components/Toggle.css similarity index 100% rename from src/components/Inputs/components/Toggle.css rename to src/components/Form/components/Toggle.css diff --git a/src/components/Inputs/components/Toggle.tsx b/src/components/Form/components/Toggle.tsx similarity index 100% rename from src/components/Inputs/components/Toggle.tsx rename to src/components/Form/components/Toggle.tsx diff --git a/src/components/Inputs/index.tsx b/src/components/Form/index.tsx similarity index 100% rename from src/components/Inputs/index.tsx rename to src/components/Form/index.tsx diff --git a/src/components/Inputs/Input.css b/src/components/Inputs/Input.css deleted file mode 100644 index e8fc4c3..0000000 --- a/src/components/Inputs/Input.css +++ /dev/null @@ -1,15 +0,0 @@ -.bbn-input { - @apply relative 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; - } - - &-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; - } -} \ No newline at end of file diff --git a/src/components/Inputs/Input.tsx b/src/components/Inputs/Input.tsx deleted file mode 100644 index 3960863..0000000 --- a/src/components/Inputs/Input.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { forwardRef, type DetailedHTMLProps, type InputHTMLAttributes, type ReactNode } from "react"; -import { twJoin } from "tailwind-merge"; -import { Loader } from "../Loader"; -import "./Input.css"; - -export interface InputProps - extends Omit, HTMLInputElement>, "prefix" | "suffix"> { - className?: string; - wrapperClassName?: string; - suffix?: ReactNode; - isLoading?: boolean; - onSuffixClick?: () => void; -} - -export const Input = forwardRef( - ({ className, wrapperClassName, suffix, isLoading, onSuffixClick, disabled, ...props }, ref) => { - return ( -
- - {suffix && ( -
- {isLoading ? : suffix} -
- )} -
- ); - }, -); - -Input.displayName = "Input"; diff --git a/src/components/Inputs/Select.css b/src/components/Inputs/Select.css deleted file mode 100644 index 22f2ea3..0000000 --- a/src/components/Inputs/Select.css +++ /dev/null @@ -1,31 +0,0 @@ -.bbn-select { - @apply relative; - - &-trigger { - @apply flex w-full cursor-pointer items-center justify-between rounded border border-primary-light/20 bg-secondary-contrast px-4 py-2 text-sm text-primary-light transition-colors; - - &:focus-within { - @apply border-primary-light; - } - } - - &-icon { - @apply ml-2 text-primary-light/50 transition-transform; - - &-open { - @apply rotate-180; - } - } - - &-menu { - @apply z-50 mt-1 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-2 text-sm text-primary-light transition-colors hover:bg-primary-light/10; - - &-selected { - @apply bg-primary-light/10; - } - } -} \ No newline at end of file diff --git a/src/components/Inputs/Select.stories.tsx b/src/components/Inputs/Select.stories.tsx deleted file mode 100644 index 7560406..0000000 --- a/src/components/Inputs/Select.stories.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import type { Meta, StoryObj } from "@storybook/react"; -import { useState } from "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", - }, -}; - -export const Controlled: Story = { - args: { - options, - placeholder: "Select status", - }, - render: (args) => { - const [value, setValue] = useState("active"); - - return ( -